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/src/client.ts
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
import { actorDirectoryInput } from './actor-directory.js';
|
|
2
|
+
import type { ActorDirectoryInput, ActorProfileInput } from './actor-directory.js';
|
|
3
|
+
import type { BookmarkInput } from './bookmarks.js';
|
|
4
|
+
import type { SearchInput, SearchPage } from './search.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import type { NotificationInput } from './notifications.js';
|
|
7
|
+
import type {
|
|
8
|
+
ReplyCreateInput,
|
|
9
|
+
ReplyDeleteInput,
|
|
10
|
+
ReplyRecord,
|
|
11
|
+
ReactionSetInput,
|
|
12
|
+
ThreadInput,
|
|
13
|
+
} from './participation.js';
|
|
14
|
+
import {
|
|
15
|
+
threadInputSchema,
|
|
16
|
+
threadSubscriptionSchema,
|
|
17
|
+
replyCreateSchema,
|
|
18
|
+
replyDeleteSchema,
|
|
19
|
+
reactionSetSchema,
|
|
20
|
+
} from './schemas.js';
|
|
21
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
22
|
+
import { cursorFilter } from './cursors.js';
|
|
23
|
+
import {
|
|
24
|
+
isRecordAdministrator,
|
|
25
|
+
localOwnerAuthority,
|
|
26
|
+
overseesAgent,
|
|
27
|
+
resolveAuthority,
|
|
28
|
+
sameActor,
|
|
29
|
+
} from './policy.js';
|
|
30
|
+
import type { ActorRef, AddressableActor, RecordAuthority } from './policy.js';
|
|
1
31
|
import { accessSync, constants as fsConstants, existsSync, lstatSync } from 'node:fs';
|
|
2
32
|
import { join, relative, resolve } from 'node:path';
|
|
3
33
|
import { ulid } from 'ulid';
|
|
@@ -16,6 +46,8 @@ import {
|
|
|
16
46
|
} from './projections.js';
|
|
17
47
|
import {
|
|
18
48
|
actorSchema,
|
|
49
|
+
eventSchema,
|
|
50
|
+
overrideTaskDecisionSchema,
|
|
19
51
|
agentLookupSchema,
|
|
20
52
|
bindRuntimeSchema,
|
|
21
53
|
createAgentSchema,
|
|
@@ -79,6 +111,7 @@ import type {
|
|
|
79
111
|
UpdateTaskInput,
|
|
80
112
|
UpdateTodoInput,
|
|
81
113
|
TaskRecord,
|
|
114
|
+
OverrideTaskDecisionInput,
|
|
82
115
|
TodoRecord,
|
|
83
116
|
ItemListInput,
|
|
84
117
|
ItemRecord,
|
|
@@ -103,7 +136,6 @@ import type {
|
|
|
103
136
|
Page,
|
|
104
137
|
UpdateAgentInput,
|
|
105
138
|
} from './types.js';
|
|
106
|
-
|
|
107
139
|
export interface SynomemCoreOptions {
|
|
108
140
|
repository: SynomemRepository;
|
|
109
141
|
projectionWriter: ProjectionWriter;
|
|
@@ -111,10 +143,10 @@ export interface SynomemCoreOptions {
|
|
|
111
143
|
clock?: () => Date;
|
|
112
144
|
idGenerator?: () => string;
|
|
113
145
|
signal?: AbortSignal;
|
|
114
|
-
/**
|
|
115
|
-
|
|
146
|
+
/** Authority from the trusted adapter; defaults to no administration. */
|
|
147
|
+
authority?: RecordAuthority;
|
|
148
|
+
canWrite?: boolean;
|
|
116
149
|
}
|
|
117
|
-
|
|
118
150
|
export class SynomemCore implements SynomemDomainService {
|
|
119
151
|
/**
|
|
120
152
|
* Mutable because an agent actor is resolved to its canonical identity on
|
|
@@ -126,9 +158,319 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
126
158
|
private readonly clock: () => Date;
|
|
127
159
|
private readonly idGenerator: () => string;
|
|
128
160
|
private readonly signal?: AbortSignal;
|
|
129
|
-
|
|
161
|
+
protected administrative: boolean;
|
|
162
|
+
private writeAllowed: boolean;
|
|
163
|
+
private readonly mutationContext = new AsyncLocalStorage<{
|
|
164
|
+
key?: string;
|
|
165
|
+
projections: Set<string>;
|
|
166
|
+
}>();
|
|
167
|
+
protected authority: RecordAuthority;
|
|
130
168
|
private initialized = false;
|
|
131
|
-
|
|
169
|
+
readonly bookmarks = {
|
|
170
|
+
list: (input: BookmarkInput = {}) => {
|
|
171
|
+
const parsed = this.validate(() =>
|
|
172
|
+
z
|
|
173
|
+
.object({
|
|
174
|
+
after: z.string().max(4096).optional(),
|
|
175
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
176
|
+
})
|
|
177
|
+
.strict()
|
|
178
|
+
.parse(input),
|
|
179
|
+
);
|
|
180
|
+
return this.repository.bookmarks.list(parsed, this.actor);
|
|
181
|
+
},
|
|
182
|
+
has: async (rootId: string) => {
|
|
183
|
+
await this.getItem(rootId);
|
|
184
|
+
return { saved: await this.repository.bookmarks.has(rootId, this.boundRef()) };
|
|
185
|
+
},
|
|
186
|
+
set: async (input: { rootId: string; present: boolean }) => {
|
|
187
|
+
const parsed = this.validate(() =>
|
|
188
|
+
z
|
|
189
|
+
.object({ rootId: z.string().length(26), present: z.boolean() })
|
|
190
|
+
.strict()
|
|
191
|
+
.parse(input),
|
|
192
|
+
);
|
|
193
|
+
if (!this.writeAllowed)
|
|
194
|
+
throw new SynomemError('READ_ONLY', 'This binding cannot change saved records.');
|
|
195
|
+
await this.repository.transaction(async () => {
|
|
196
|
+
await this.resolveActor(this.boundRef(), true);
|
|
197
|
+
await this.getItem(parsed.rootId);
|
|
198
|
+
await this.repository.bookmarks.set(parsed.rootId, this.boundRef(), parsed.present);
|
|
199
|
+
});
|
|
200
|
+
return { saved: parsed.present };
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
readonly notifications = {
|
|
204
|
+
list: async (input: NotificationInput = {}) => {
|
|
205
|
+
const parsed = this.validate(() =>
|
|
206
|
+
z
|
|
207
|
+
.object({
|
|
208
|
+
after: z.string().max(4096).optional(),
|
|
209
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
210
|
+
view: z.enum(['all', 'unread', 'action_required', 'default']).optional(),
|
|
211
|
+
})
|
|
212
|
+
.strict()
|
|
213
|
+
.parse(input),
|
|
214
|
+
);
|
|
215
|
+
if (this.writeAllowed)
|
|
216
|
+
await this.repository.transaction(() => this.repository.notifications.drain());
|
|
217
|
+
const page = await this.repository.notifications.list(parsed, this.actor);
|
|
218
|
+
return page;
|
|
219
|
+
},
|
|
220
|
+
read: (id: string) =>
|
|
221
|
+
this.personalWrite(() => this.repository.notifications.read(id, this.boundRef())),
|
|
222
|
+
dismiss: (id: string) =>
|
|
223
|
+
this.personalWrite(() => this.repository.notifications.dismiss(id, this.boundRef())),
|
|
224
|
+
readThrough: (through: string) =>
|
|
225
|
+
this.personalWrite(() => this.repository.notifications.readThrough(through, this.boundRef())),
|
|
226
|
+
};
|
|
227
|
+
readonly replies = {
|
|
228
|
+
changes: async (input: ThreadInput) => {
|
|
229
|
+
const parsed = this.validate(() => threadInputSchema.parse(input));
|
|
230
|
+
await this.getItem(parsed.rootId);
|
|
231
|
+
return this.repository.participation.changes(parsed, this.actor);
|
|
232
|
+
},
|
|
233
|
+
create: (input: ReplyCreateInput) =>
|
|
234
|
+
this.mutate('replies.create', input, () => this.createReply(input)),
|
|
235
|
+
get: (id: string) => this.getReply(id),
|
|
236
|
+
delete: (input: ReplyDeleteInput) =>
|
|
237
|
+
this.mutate('replies.delete', input, () => this.deleteReply(input)),
|
|
238
|
+
};
|
|
239
|
+
readonly threads = {
|
|
240
|
+
read: async (input: { rootId: string; through: string }) => {
|
|
241
|
+
const parsed = this.validate(() =>
|
|
242
|
+
z
|
|
243
|
+
.object({ rootId: z.string().length(26), through: z.string().max(4096) })
|
|
244
|
+
.strict()
|
|
245
|
+
.parse(input),
|
|
246
|
+
);
|
|
247
|
+
await this.personalWrite(async () => {
|
|
248
|
+
await this.getItem(parsed.rootId);
|
|
249
|
+
await this.repository.participation.read(parsed.rootId, parsed.through, this.boundRef());
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
get: async (input: ThreadInput) => {
|
|
253
|
+
const parsed = this.validate(() => threadInputSchema.parse(input));
|
|
254
|
+
await this.getItem(parsed.rootId);
|
|
255
|
+
return this.repository.participation.thread(parsed, this.actor);
|
|
256
|
+
},
|
|
257
|
+
subscription: async (input: { rootId: string; following: boolean; muted: boolean }) => {
|
|
258
|
+
const parsed = this.validate(() => threadSubscriptionSchema.parse(input));
|
|
259
|
+
await this.personalWrite(async () => {
|
|
260
|
+
await this.getItem(parsed.rootId);
|
|
261
|
+
await this.repository.participation.subscription(
|
|
262
|
+
parsed.rootId,
|
|
263
|
+
this.boundRef(),
|
|
264
|
+
parsed.following,
|
|
265
|
+
parsed.muted,
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
readonly reactions = {
|
|
271
|
+
set: (input: ReactionSetInput) =>
|
|
272
|
+
this.mutate('reactions.set', input, () => this.setReaction(input)),
|
|
273
|
+
get: async (targetId: string) => {
|
|
274
|
+
await this.reactionRoot(targetId);
|
|
275
|
+
return this.repository.participation.reactions(targetId, this.boundRef());
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
private allowedActions(record: ItemRecord): Record<string, boolean> {
|
|
279
|
+
const event = record.event;
|
|
280
|
+
const active =
|
|
281
|
+
record.status !== 'archived' &&
|
|
282
|
+
(!('revocationStatus' in record) || record.revocationStatus !== 'revoked');
|
|
283
|
+
const owner = 'owner' in event ? event.owner : event.actor;
|
|
284
|
+
const participant =
|
|
285
|
+
'assignee' in event &&
|
|
286
|
+
(this.canManage(event.assignee) || this.canManage(event.actor as ActorRef));
|
|
287
|
+
const managed =
|
|
288
|
+
event.type === 'memo.sent'
|
|
289
|
+
? this.canManage(event.recipient)
|
|
290
|
+
: event.type === 'task.created'
|
|
291
|
+
? participant
|
|
292
|
+
: this.canManage(owner as ActorRef);
|
|
293
|
+
const flags: Record<string, boolean> = {
|
|
294
|
+
reply: active,
|
|
295
|
+
edit:
|
|
296
|
+
active &&
|
|
297
|
+
managed &&
|
|
298
|
+
['post.created', 'note.created', 'task.created', 'todo.created'].includes(event.type),
|
|
299
|
+
archive: active && managed && !['task.created', 'kudos.given'].includes(event.type),
|
|
300
|
+
moderateReplies: this.canModerateRoot(record),
|
|
301
|
+
acknowledge:
|
|
302
|
+
event.type === 'kudos.given'
|
|
303
|
+
? active && sameActor(this.actor, event.recipient) && record.status === 'unacknowledged'
|
|
304
|
+
: event.type === 'post.created'
|
|
305
|
+
? active &&
|
|
306
|
+
'acknowledgments' in record &&
|
|
307
|
+
!record.acknowledgments.some((entry) => sameActor(this.actor, entry.actor))
|
|
308
|
+
: false,
|
|
309
|
+
withdrawAcknowledgment:
|
|
310
|
+
event.type === 'post.created' &&
|
|
311
|
+
active &&
|
|
312
|
+
'acknowledgments' in record &&
|
|
313
|
+
record.acknowledgments.some((entry) => sameActor(this.actor, entry.actor)),
|
|
314
|
+
readMemo:
|
|
315
|
+
event.type === 'memo.sent' &&
|
|
316
|
+
active &&
|
|
317
|
+
sameActor(this.actor, event.recipient) &&
|
|
318
|
+
record.status === 'unread',
|
|
319
|
+
revoke: event.type === 'kudos.given' && active && this.canManage(event.actor as ActorRef),
|
|
320
|
+
};
|
|
321
|
+
if (event.type === 'task.created')
|
|
322
|
+
Object.assign(flags, {
|
|
323
|
+
accept: record.status === 'assigned' && this.canManage(event.assignee),
|
|
324
|
+
reject: record.status === 'assigned' && this.canManage(event.assignee),
|
|
325
|
+
complete: record.status === 'open' && participant,
|
|
326
|
+
reopen: ['completed', 'canceled'].includes(record.status) && participant,
|
|
327
|
+
cancel: ['assigned', 'open'].includes(record.status) && participant,
|
|
328
|
+
overrideDecision:
|
|
329
|
+
['open', 'rejected'].includes(record.status) &&
|
|
330
|
+
this.actor.kind === 'human' &&
|
|
331
|
+
(this.administrative ||
|
|
332
|
+
(event.assignee.kind === 'agent' &&
|
|
333
|
+
overseesAgent(this.actor, this.authority, event.assignee.id)) ||
|
|
334
|
+
(event.actor.kind === 'agent' &&
|
|
335
|
+
overseesAgent(this.actor, this.authority, event.actor.id))),
|
|
336
|
+
});
|
|
337
|
+
if (event.type === 'todo.created')
|
|
338
|
+
Object.assign(flags, {
|
|
339
|
+
complete: record.status === 'open' && managed,
|
|
340
|
+
reopen: ['completed', 'canceled'].includes(record.status) && managed,
|
|
341
|
+
cancel: record.status === 'open' && managed,
|
|
342
|
+
});
|
|
343
|
+
return Object.fromEntries(
|
|
344
|
+
Object.entries(flags).map(([name, allowed]) => [name, this.writeAllowed && allowed]),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
private async decorateRecord<T extends ItemRecord>(record: T): Promise<T> {
|
|
348
|
+
return {
|
|
349
|
+
...record,
|
|
350
|
+
lifecycleVersion: (await this.repository.nextAggregateVersion(record.event.id)) - 1,
|
|
351
|
+
allowedActions: this.allowedActions(record),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
private async getReply(id: string): Promise<ReplyRecord> {
|
|
355
|
+
const reply = await this.repository.participation.getReply(id);
|
|
356
|
+
if (!reply) throw new SynomemError('ITEM_NOT_FOUND', 'Unknown reply.');
|
|
357
|
+
await this.getItem(reply.rootId);
|
|
358
|
+
return reply;
|
|
359
|
+
}
|
|
360
|
+
private async createReply(input: ReplyCreateInput): Promise<ReplyRecord> {
|
|
361
|
+
await this.repository.assertEventCompatibility();
|
|
362
|
+
const parsed = this.validate(() => replyCreateSchema.parse(input));
|
|
363
|
+
const root = await this.getItem(parsed.rootId);
|
|
364
|
+
if (
|
|
365
|
+
root.status === 'archived' ||
|
|
366
|
+
('revocationStatus' in root && root.revocationStatus === 'revoked')
|
|
367
|
+
)
|
|
368
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'This root no longer accepts replies.');
|
|
369
|
+
if (parsed.parentId) {
|
|
370
|
+
const parent = await this.getReply(parsed.parentId);
|
|
371
|
+
if (parent.rootId !== parsed.rootId)
|
|
372
|
+
throw new SynomemError('INVALID_INPUT', 'The parent must belong to this thread.');
|
|
373
|
+
}
|
|
374
|
+
const mentions: ActorRef[] = [];
|
|
375
|
+
for (const supplied of parsed.mentions ?? []) {
|
|
376
|
+
const target = await this.resolveActor(supplied, true);
|
|
377
|
+
const ref = { kind: target.kind, id: target.id };
|
|
378
|
+
if (!(await this.repository.canActorReadItem(parsed.rootId, ref)))
|
|
379
|
+
throw new SynomemError('INVALID_INPUT', 'Mention targets must already have access.');
|
|
380
|
+
if (!mentions.some((actor) => sameActor(actor, ref))) mentions.push(ref);
|
|
381
|
+
}
|
|
382
|
+
await this.repository.participation.reserveBudget('reply', this.boundRef(), this.now());
|
|
383
|
+
const id = this.nextId();
|
|
384
|
+
await this.repository.insertEvent({
|
|
385
|
+
...this.eventBase(id, 1, id),
|
|
386
|
+
type: 'reply.created',
|
|
387
|
+
rootId: parsed.rootId,
|
|
388
|
+
parentId: parsed.parentId ?? null,
|
|
389
|
+
body: parsed.body,
|
|
390
|
+
mentions,
|
|
391
|
+
});
|
|
392
|
+
return this.getReply(id);
|
|
393
|
+
}
|
|
394
|
+
private canModerateRoot(root: ItemRecord): boolean {
|
|
395
|
+
if (this.actor.kind !== 'human') return false;
|
|
396
|
+
const event = root.event;
|
|
397
|
+
if (this.administrative || sameActor(event.actor, this.actor)) return true;
|
|
398
|
+
const targets = [
|
|
399
|
+
event.actor,
|
|
400
|
+
...('recipient' in event ? [event.recipient] : []),
|
|
401
|
+
...('owner' in event ? [event.owner] : []),
|
|
402
|
+
...('assignee' in event ? [event.assignee] : []),
|
|
403
|
+
];
|
|
404
|
+
return targets.some(
|
|
405
|
+
(ref) => ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
private async deleteReply(input: ReplyDeleteInput): Promise<ReplyRecord> {
|
|
409
|
+
await this.repository.assertEventCompatibility();
|
|
410
|
+
const parsed = this.validate(() => replyDeleteSchema.parse(input));
|
|
411
|
+
const reply = await this.getReply(parsed.replyId);
|
|
412
|
+
const root = await this.getItem(reply.rootId);
|
|
413
|
+
const own = sameActor(reply.author, this.actor);
|
|
414
|
+
if (!own && !this.canModerateRoot(root))
|
|
415
|
+
throw new SynomemError(
|
|
416
|
+
'MUTATION_FORBIDDEN',
|
|
417
|
+
'Only the reply author or an authorized human moderator may delete it.',
|
|
418
|
+
);
|
|
419
|
+
if (!own && !parsed.reason)
|
|
420
|
+
throw new SynomemError('INVALID_INPUT', 'Moderation requires a reason.');
|
|
421
|
+
if (reply.version !== parsed.expectedVersion)
|
|
422
|
+
throw new SynomemError('REVISION_CONFLICT', 'The reply changed. Refresh before deleting.');
|
|
423
|
+
if (reply.deleted) return reply;
|
|
424
|
+
await this.repository.insertEvent({
|
|
425
|
+
...this.eventBase(reply.id, reply.version + 1),
|
|
426
|
+
...(!own ? this.intervention(reply.author as ActorRef, parsed.reason) : {}),
|
|
427
|
+
type: 'reply.deleted',
|
|
428
|
+
replyId: reply.id,
|
|
429
|
+
rootId: reply.rootId,
|
|
430
|
+
...(parsed.reason ? { reason: parsed.reason } : {}),
|
|
431
|
+
});
|
|
432
|
+
return this.getReply(reply.id);
|
|
433
|
+
}
|
|
434
|
+
private async reactionRoot(targetId: string): Promise<string> {
|
|
435
|
+
const reply = await this.repository.participation.getReply(targetId);
|
|
436
|
+
if (reply) {
|
|
437
|
+
await this.getItem(reply.rootId);
|
|
438
|
+
if (reply.deleted)
|
|
439
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Deleted replies cannot receive reactions.');
|
|
440
|
+
return reply.rootId;
|
|
441
|
+
}
|
|
442
|
+
await this.getItem(targetId);
|
|
443
|
+
return targetId;
|
|
444
|
+
}
|
|
445
|
+
private async setReaction(input: ReactionSetInput) {
|
|
446
|
+
await this.repository.assertEventCompatibility();
|
|
447
|
+
const parsed = this.validate(() => reactionSetSchema.parse(input));
|
|
448
|
+
const rootId = await this.reactionRoot(parsed.targetId);
|
|
449
|
+
const actor = this.boundRef();
|
|
450
|
+
const present = await this.repository.participation.hasReaction(
|
|
451
|
+
parsed.targetId,
|
|
452
|
+
actor,
|
|
453
|
+
parsed.code,
|
|
454
|
+
);
|
|
455
|
+
if (present !== parsed.present) {
|
|
456
|
+
await this.repository.participation.reserveBudget('reaction', actor, this.now());
|
|
457
|
+
const id = this.nextId();
|
|
458
|
+
await this.repository.insertEvent({
|
|
459
|
+
...this.eventBase(id, 1, id),
|
|
460
|
+
type: parsed.present ? 'reaction.added' : 'reaction.removed',
|
|
461
|
+
rootId,
|
|
462
|
+
targetId: parsed.targetId,
|
|
463
|
+
code: parsed.code,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return this.repository.participation.reactions(parsed.targetId, actor);
|
|
467
|
+
}
|
|
468
|
+
async search(input: SearchInput): Promise<SearchPage> {
|
|
469
|
+
this.checkAbort();
|
|
470
|
+
if (!this.repository.search)
|
|
471
|
+
throw new SynomemError('UNSUPPORTED_BACKEND', 'Search is available on hosted workspaces.');
|
|
472
|
+
return this.repository.search(input, this.actor);
|
|
473
|
+
}
|
|
132
474
|
readonly agents = {
|
|
133
475
|
create: (input: CreateAgentInput) => this.createAgent(input),
|
|
134
476
|
update: (id: string, changes: UpdateAgentInput) => this.updateAgent(id, changes),
|
|
@@ -145,7 +487,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
145
487
|
bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
|
|
146
488
|
unbindRuntime: (bindingId: string) => this.unbindRuntime(bindingId),
|
|
147
489
|
};
|
|
148
|
-
|
|
149
490
|
readonly topics = {
|
|
150
491
|
create: (input: CreateTopicInput) => this.createTopic(input),
|
|
151
492
|
update: (idOrAlias: string, changes: UpdateTopicInput) =>
|
|
@@ -156,91 +497,147 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
156
497
|
archive: (idOrAlias: string) => this.setTopicStatus(idOrAlias, 'archived'),
|
|
157
498
|
restore: (idOrAlias: string) => this.setTopicStatus(idOrAlias, 'active'),
|
|
158
499
|
};
|
|
159
|
-
|
|
160
500
|
readonly kudos = {
|
|
161
|
-
give: (input: GiveKudosInput) => this.giveKudos(input),
|
|
501
|
+
give: (input: GiveKudosInput) => this.mutate('kudos.give', input, () => this.giveKudos(input)),
|
|
162
502
|
list: (input: KudosListInput = {}) => this.listKudos(input),
|
|
163
503
|
changes: (input: KudosChangesInput = {}) => this.listKudosChanges(input),
|
|
164
504
|
get: (id: string) => this.getKudos(id),
|
|
165
|
-
acknowledge: (input: {
|
|
166
|
-
|
|
167
|
-
|
|
505
|
+
acknowledge: (input: {
|
|
506
|
+
expectedVersion: number;
|
|
507
|
+
kudosId: string;
|
|
508
|
+
note?: string;
|
|
509
|
+
idempotencyKey?: string;
|
|
510
|
+
}) => this.mutate('kudos.acknowledge', input, () => this.acknowledgeKudos(input)),
|
|
511
|
+
revoke: (input: {
|
|
512
|
+
expectedVersion: number;
|
|
513
|
+
kudosId: string;
|
|
514
|
+
reason: string;
|
|
515
|
+
administrative?: boolean;
|
|
516
|
+
idempotencyKey?: string;
|
|
517
|
+
}) => this.mutate('kudos.revoke', input, () => this.revokeKudos(input)),
|
|
168
518
|
};
|
|
169
|
-
|
|
170
519
|
readonly memos = {
|
|
171
|
-
send: (input: SendMemoInput) => this.sendMemo(input),
|
|
520
|
+
send: (input: SendMemoInput) => this.mutate('memos.send', input, () => this.sendMemo(input)),
|
|
172
521
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
173
522
|
this.listItems({ ...input, kinds: ['memo'] }),
|
|
174
523
|
get: (id: string) => this.getMemo(id),
|
|
175
|
-
read: (input: { memoId: string; idempotencyKey?: string }) =>
|
|
176
|
-
|
|
524
|
+
read: (input: { expectedVersion: number; memoId: string; idempotencyKey?: string }) =>
|
|
525
|
+
this.mutate('memos.read', input, () => this.readMemo(input)),
|
|
526
|
+
archive: (input: { expectedVersion: number; memoId: string; idempotencyKey?: string }) =>
|
|
527
|
+
this.mutate('memos.archive', input, () => this.archiveMemo(input)),
|
|
177
528
|
};
|
|
178
|
-
|
|
179
529
|
readonly posts = {
|
|
180
|
-
create: (input: CreatePostInput) =>
|
|
530
|
+
create: (input: CreatePostInput) =>
|
|
531
|
+
this.mutate('posts.create', input, () => this.createPost(input)),
|
|
181
532
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
182
533
|
this.listItems({ ...input, kinds: ['post'] }),
|
|
183
534
|
get: (id: string) => this.getPost(id),
|
|
184
|
-
update: (input: UpdatePostInput) =>
|
|
185
|
-
|
|
186
|
-
|
|
535
|
+
update: (input: UpdatePostInput) =>
|
|
536
|
+
this.mutate('posts.update', input, () => this.updatePost(input)),
|
|
537
|
+
archive: (input: {
|
|
538
|
+
expectedVersion: number;
|
|
539
|
+
postId: string;
|
|
540
|
+
reason?: string;
|
|
541
|
+
idempotencyKey?: string;
|
|
542
|
+
}) => this.mutate('posts.archive', input, () => this.archivePost(input)),
|
|
187
543
|
/*
|
|
188
544
|
* Acknowledging is an explicit call and always speaks for the caller alone.
|
|
189
545
|
* There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
|
|
190
546
|
* is one actor saying "I have seen this", and reading a post must never
|
|
191
547
|
* append one, or the roster stops meaning anything.
|
|
192
548
|
*/
|
|
193
|
-
acknowledge: (input: {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
549
|
+
acknowledge: (input: {
|
|
550
|
+
expectedVersion: number;
|
|
551
|
+
postId: string;
|
|
552
|
+
note?: string;
|
|
553
|
+
idempotencyKey?: string;
|
|
554
|
+
}) => this.mutate('posts.acknowledge', input, () => this.acknowledgePost(input)),
|
|
555
|
+
withdrawAcknowledgment: (input: {
|
|
556
|
+
expectedVersion: number;
|
|
557
|
+
postId: string;
|
|
558
|
+
reason?: string;
|
|
559
|
+
idempotencyKey?: string;
|
|
560
|
+
}) =>
|
|
561
|
+
this.mutate('posts.withdrawAcknowledgment', input, () =>
|
|
562
|
+
this.withdrawPostAcknowledgment(input),
|
|
563
|
+
),
|
|
197
564
|
roster: (postId: string) => this.postRoster(postId),
|
|
198
565
|
};
|
|
199
|
-
|
|
200
566
|
readonly notes = {
|
|
201
|
-
create: (input: CreateNoteInput) =>
|
|
567
|
+
create: (input: CreateNoteInput) =>
|
|
568
|
+
this.mutate('notes.create', input, () => this.createNote(input)),
|
|
202
569
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
203
570
|
this.listItems({ ...input, kinds: ['note'] }),
|
|
204
571
|
get: (id: string) => this.getNote(id),
|
|
205
|
-
revise: (input: ReviseNoteInput) =>
|
|
206
|
-
|
|
572
|
+
revise: (input: ReviseNoteInput) =>
|
|
573
|
+
this.mutate('notes.revise', input, () => this.reviseNote(input)),
|
|
574
|
+
archive: (input: { expectedVersion: number; noteId: string; idempotencyKey?: string }) =>
|
|
575
|
+
this.mutate('notes.archive', input, () => this.archiveNote(input)),
|
|
207
576
|
};
|
|
208
|
-
|
|
209
577
|
readonly tasks = {
|
|
210
|
-
|
|
578
|
+
overrideDecision: (input: OverrideTaskDecisionInput) =>
|
|
579
|
+
this.mutate('tasks.overrideDecision', input, () => this.overrideTaskDecision(input)),
|
|
580
|
+
create: (input: CreateTaskInput) =>
|
|
581
|
+
this.mutate('tasks.create', input, () => this.createTask(input)),
|
|
211
582
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
212
583
|
this.listItems({ ...input, kinds: ['task'] }),
|
|
213
584
|
get: (id: string) => this.getTask(id),
|
|
214
|
-
update: (input: UpdateTaskInput) =>
|
|
585
|
+
update: (input: UpdateTaskInput) =>
|
|
586
|
+
this.mutate('tasks.update', input, () => this.updateTask(input)),
|
|
215
587
|
// A response is optional when accepting and required when rejecting: a
|
|
216
588
|
// refusal without a reason leaves the assigner unable to act on it.
|
|
217
|
-
accept: (input: {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
589
|
+
accept: (input: {
|
|
590
|
+
expectedVersion: number;
|
|
591
|
+
taskId: string;
|
|
592
|
+
response?: string;
|
|
593
|
+
idempotencyKey?: string;
|
|
594
|
+
}) => this.mutate('tasks.accept', input, () => this.acceptTask(input)),
|
|
595
|
+
reject: (input: {
|
|
596
|
+
expectedVersion: number;
|
|
597
|
+
taskId: string;
|
|
598
|
+
response: string;
|
|
599
|
+
idempotencyKey?: string;
|
|
600
|
+
}) => this.mutate('tasks.reject', input, () => this.rejectTask(input)),
|
|
601
|
+
complete: (input: {
|
|
602
|
+
expectedVersion: number;
|
|
603
|
+
taskId: string;
|
|
604
|
+
note?: string;
|
|
605
|
+
idempotencyKey?: string;
|
|
606
|
+
}) => this.mutate('tasks.complete', input, () => this.completeTask(input)),
|
|
607
|
+
reopen: (input: { expectedVersion: number; taskId: string; idempotencyKey?: string }) =>
|
|
608
|
+
this.mutate('tasks.reopen', input, () => this.reopenTask(input)),
|
|
609
|
+
cancel: (input: {
|
|
610
|
+
expectedVersion: number;
|
|
611
|
+
taskId: string;
|
|
612
|
+
reason?: string;
|
|
613
|
+
idempotencyKey?: string;
|
|
614
|
+
}) => this.mutate('tasks.cancel', input, () => this.cancelTask(input)),
|
|
226
615
|
};
|
|
227
|
-
|
|
228
616
|
readonly todos = {
|
|
229
|
-
create: (input: CreateTodoInput) =>
|
|
617
|
+
create: (input: CreateTodoInput) =>
|
|
618
|
+
this.mutate('todos.create', input, () => this.createTodo(input)),
|
|
230
619
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
231
620
|
this.listItems({ ...input, kinds: ['todo'] }),
|
|
232
621
|
get: (id: string) => this.getTodo(id),
|
|
233
|
-
update: (input: UpdateTodoInput) =>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
622
|
+
update: (input: UpdateTodoInput) =>
|
|
623
|
+
this.mutate('todos.update', input, () => this.updateTodo(input)),
|
|
624
|
+
complete: (input: {
|
|
625
|
+
expectedVersion: number;
|
|
626
|
+
todoId: string;
|
|
627
|
+
note?: string;
|
|
628
|
+
idempotencyKey?: string;
|
|
629
|
+
}) => this.mutate('todos.complete', input, () => this.todoTransition(input, 'todo.completed')),
|
|
630
|
+
reopen: (input: { expectedVersion: number; todoId: string; idempotencyKey?: string }) =>
|
|
631
|
+
this.mutate('todos.reopen', input, () => this.todoTransition(input, 'todo.reopened')),
|
|
632
|
+
cancel: (input: {
|
|
633
|
+
expectedVersion: number;
|
|
634
|
+
todoId: string;
|
|
635
|
+
reason?: string;
|
|
636
|
+
idempotencyKey?: string;
|
|
637
|
+
}) => this.mutate('todos.cancel', input, () => this.todoTransition(input, 'todo.canceled')),
|
|
638
|
+
archive: (input: { expectedVersion: number; todoId: string; idempotencyKey?: string }) =>
|
|
639
|
+
this.mutate('todos.archive', input, () => this.todoTransition(input, 'todo.archived')),
|
|
242
640
|
};
|
|
243
|
-
|
|
244
641
|
/**
|
|
245
642
|
* Unanswered and overdue discovery.
|
|
246
643
|
*
|
|
@@ -255,13 +652,15 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
255
652
|
* optionally only those older than a given age or instant.
|
|
256
653
|
*/
|
|
257
654
|
unanswered: (
|
|
258
|
-
input: Omit<ItemListInput, 'awaitingResponse' | 'pending'> & {
|
|
655
|
+
input: Omit<ItemListInput, 'awaitingResponse' | 'pending'> & {
|
|
656
|
+
olderThanHours?: number;
|
|
657
|
+
} = {},
|
|
259
658
|
) => {
|
|
260
659
|
const { olderThanHours, awaitingSince, ...rest } = input;
|
|
261
660
|
const since =
|
|
262
661
|
awaitingSince ??
|
|
263
662
|
(olderThanHours !== undefined
|
|
264
|
-
? new Date(Date.now() - olderThanHours *
|
|
663
|
+
? new Date(Date.now() - olderThanHours * 3600000).toISOString()
|
|
265
664
|
: undefined);
|
|
266
665
|
return this.listItems({
|
|
267
666
|
...rest,
|
|
@@ -270,18 +669,20 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
270
669
|
});
|
|
271
670
|
},
|
|
272
671
|
/** Open work whose deadline has passed. Defaults to "now". */
|
|
273
|
-
overdue: (
|
|
672
|
+
overdue: (
|
|
673
|
+
input: Omit<ItemListInput, 'overdueAsOf'> & {
|
|
674
|
+
asOf?: string;
|
|
675
|
+
} = {},
|
|
676
|
+
) => {
|
|
274
677
|
const { asOf, ...rest } = input;
|
|
275
678
|
return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
|
|
276
679
|
},
|
|
277
680
|
};
|
|
278
|
-
|
|
279
681
|
readonly items = {
|
|
280
682
|
list: (input: ItemListInput = {}) => this.listItems(input),
|
|
281
683
|
get: (id: string) => this.getItem(id),
|
|
282
684
|
changes: (input: ChangesInput = {}) => this.listItemChanges(input),
|
|
283
685
|
};
|
|
284
|
-
|
|
285
686
|
constructor(options: SynomemCoreOptions) {
|
|
286
687
|
try {
|
|
287
688
|
this.actor = actorSchema.parse(options.actor ?? { kind: 'system', id: 'workspace' });
|
|
@@ -291,17 +692,39 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
291
692
|
this.clock = options.clock ?? (() => new Date());
|
|
292
693
|
this.idGenerator = options.idGenerator ?? (() => ulid(this.clock().getTime()));
|
|
293
694
|
this.signal = options.signal;
|
|
294
|
-
this.
|
|
695
|
+
this.authority = resolveAuthority(options.authority);
|
|
696
|
+
this.writeAllowed = options.canWrite ?? true;
|
|
697
|
+
this.administrative = isRecordAdministrator(this.actor, this.authority);
|
|
295
698
|
this.repository = options.repository;
|
|
296
699
|
this.projectionWriter = options.projectionWriter;
|
|
297
700
|
}
|
|
298
|
-
|
|
701
|
+
/** Trusted adapters refresh live authority after acquiring the workspace write boundary. Never exposed as a tool/input. */
|
|
702
|
+
setAuthority(authority: RecordAuthority, canWrite = this.writeAllowed): void {
|
|
703
|
+
this.writeAllowed = canWrite;
|
|
704
|
+
this.authority = resolveAuthority(authority);
|
|
705
|
+
this.administrative = isRecordAdministrator(this.actor, this.authority);
|
|
706
|
+
this.repository.setAuthority(this.authority);
|
|
707
|
+
}
|
|
299
708
|
async init(): Promise<void> {
|
|
300
709
|
this.checkAbort();
|
|
301
710
|
if (this.initialized) return;
|
|
302
711
|
await this.repository.init();
|
|
303
712
|
this.initialized = true;
|
|
304
|
-
|
|
713
|
+
if (
|
|
714
|
+
this.actor.kind === 'human' &&
|
|
715
|
+
this.authority.mode === 'local-owner' &&
|
|
716
|
+
!(await this.repository.getActor({ kind: 'human', id: this.actor.id }))
|
|
717
|
+
) {
|
|
718
|
+
await this.repository.transaction(() =>
|
|
719
|
+
this.repository.registerHuman({
|
|
720
|
+
kind: 'human',
|
|
721
|
+
id: this.actor.id,
|
|
722
|
+
handle: this.actor.id,
|
|
723
|
+
displayName: this.actor.displayName ?? this.actor.id,
|
|
724
|
+
status: 'active',
|
|
725
|
+
}),
|
|
726
|
+
);
|
|
727
|
+
}
|
|
305
728
|
/*
|
|
306
729
|
* An agent actor is resolved to its canonical identity here.
|
|
307
730
|
*
|
|
@@ -326,23 +749,393 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
326
749
|
}
|
|
327
750
|
}
|
|
328
751
|
}
|
|
329
|
-
|
|
330
752
|
async close(): Promise<void> {
|
|
331
753
|
await this.repository.close();
|
|
332
754
|
this.initialized = false;
|
|
333
755
|
}
|
|
334
|
-
|
|
756
|
+
readonly actors = {
|
|
757
|
+
profile: async (input: ActorProfileInput) => {
|
|
758
|
+
const actor = await this.resolveActor(input.target);
|
|
759
|
+
return {
|
|
760
|
+
actor,
|
|
761
|
+
counts: await this.repository.actorCounts(actor, this.actor),
|
|
762
|
+
authored: await this.listItems({
|
|
763
|
+
actorKind: actor.kind,
|
|
764
|
+
actorId: actor.id,
|
|
765
|
+
cursor: input.after,
|
|
766
|
+
limit: input.limit,
|
|
767
|
+
}),
|
|
768
|
+
};
|
|
769
|
+
},
|
|
770
|
+
list: async (input: ActorDirectoryInput = {}) => {
|
|
771
|
+
this.checkAbort();
|
|
772
|
+
return await this.repository.listActors(actorDirectoryInput(input), this.actor);
|
|
773
|
+
},
|
|
774
|
+
get: (ref: ActorRef) => this.resolveActor(ref),
|
|
775
|
+
registerHuman: async (input: { id: string; handle: string; displayName: string }) => {
|
|
776
|
+
if (this.actor.kind !== 'human' || this.authority.mode !== 'local-owner')
|
|
777
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the local owner may register a human.');
|
|
778
|
+
const ref = actorSchema.parse({
|
|
779
|
+
kind: 'human',
|
|
780
|
+
id: input.id,
|
|
781
|
+
displayName: input.displayName,
|
|
782
|
+
});
|
|
783
|
+
const handle = agentLookupSchema.parse(input.handle);
|
|
784
|
+
await this.repository.registerHuman({
|
|
785
|
+
kind: 'human',
|
|
786
|
+
id: ref.id,
|
|
787
|
+
handle,
|
|
788
|
+
displayName: input.displayName,
|
|
789
|
+
status: 'active',
|
|
790
|
+
});
|
|
791
|
+
return this.resolveActor({ kind: 'human', id: ref.id });
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
private async resolveActor(ref?: ActorRef, requireActive = false): Promise<AddressableActor> {
|
|
795
|
+
if (!ref || (ref.kind !== 'human' && ref.kind !== 'agent'))
|
|
796
|
+
throw new SynomemError('INVALID_INPUT', 'An actor reference is required.');
|
|
797
|
+
const actor = await this.repository.getActor(ref);
|
|
798
|
+
if (!actor) throw new SynomemError('AGENT_NOT_FOUND', `Unknown ${ref.kind}: ${ref.id}`);
|
|
799
|
+
if (requireActive && actor.status !== 'active')
|
|
800
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Inactive actors cannot receive new work.');
|
|
801
|
+
return actor;
|
|
802
|
+
}
|
|
803
|
+
private boundRef(): ActorRef {
|
|
804
|
+
if (this.actor.kind === 'system')
|
|
805
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'A human or agent identity is required.');
|
|
806
|
+
return { kind: this.actor.kind, id: this.actor.id };
|
|
807
|
+
}
|
|
808
|
+
private canManage(ref: ActorRef): boolean {
|
|
809
|
+
return (
|
|
810
|
+
sameActor(ref, this.actor) ||
|
|
811
|
+
this.administrative ||
|
|
812
|
+
(ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id))
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
private intervention(ref: ActorRef, reason?: string): Pick<SynomemEvent, 'intervention'> {
|
|
816
|
+
if (this.actor.kind !== 'human' || sameActor(this.actor, ref) || !this.canManage(ref))
|
|
817
|
+
return {};
|
|
818
|
+
const basis =
|
|
819
|
+
this.authority.mode === 'local-owner'
|
|
820
|
+
? 'local_owner'
|
|
821
|
+
: this.authority.organizationAdministrator
|
|
822
|
+
? 'organization_admin'
|
|
823
|
+
: this.authority.workspaceAdministrator
|
|
824
|
+
? 'workspace_admin'
|
|
825
|
+
: 'operator';
|
|
826
|
+
return { intervention: { basis, ...(reason ? { reason } : {}) } };
|
|
827
|
+
}
|
|
828
|
+
private async syncActor(ref: ActorRef): Promise<void> {
|
|
829
|
+
if (ref.kind === 'agent') {
|
|
830
|
+
const context = this.mutationContext.getStore();
|
|
831
|
+
if (context) context.projections.add(ref.id);
|
|
832
|
+
else await this.projectionWriter.syncAgent(ref.id);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
private async normalizeMutation(
|
|
836
|
+
operation: string,
|
|
837
|
+
input: object,
|
|
838
|
+
): Promise<Record<string, unknown>> {
|
|
839
|
+
const schemas: Record<string, { parse: (value: unknown) => unknown }> = {
|
|
840
|
+
'kudos.give': giveKudosSchema,
|
|
841
|
+
'replies.create': replyCreateSchema,
|
|
842
|
+
'replies.delete': replyDeleteSchema,
|
|
843
|
+
'reactions.set': reactionSetSchema,
|
|
844
|
+
'memos.send': sendMemoSchema,
|
|
845
|
+
'notes.create': createNoteSchema,
|
|
846
|
+
'notes.revise': reviseNoteSchema,
|
|
847
|
+
'posts.create': createPostSchema,
|
|
848
|
+
'posts.update': updatePostSchema,
|
|
849
|
+
'tasks.create': createTaskSchema,
|
|
850
|
+
'tasks.update': updateTaskSchema,
|
|
851
|
+
'tasks.overrideDecision': overrideTaskDecisionSchema,
|
|
852
|
+
'todos.create': createTodoSchema,
|
|
853
|
+
'todos.update': updateTodoSchema,
|
|
854
|
+
};
|
|
855
|
+
const value = this.validate(
|
|
856
|
+
() =>
|
|
857
|
+
schemas[operation]?.parse(
|
|
858
|
+
operation === 'kudos.give'
|
|
859
|
+
? {
|
|
860
|
+
...input,
|
|
861
|
+
visibility:
|
|
862
|
+
(input as GiveKudosInput).visibility ?? this.repository.config.defaultVisibility,
|
|
863
|
+
}
|
|
864
|
+
: input,
|
|
865
|
+
) ?? input,
|
|
866
|
+
) as Record<string, unknown>;
|
|
867
|
+
const normalized = { ...value };
|
|
868
|
+
delete normalized.idempotencyKey;
|
|
869
|
+
for (const field of ['recipient', 'owner', 'assignee']) {
|
|
870
|
+
const supplied = normalized[field] as ActorRef | undefined;
|
|
871
|
+
if (supplied) {
|
|
872
|
+
const actor = await this.resolveActor(supplied);
|
|
873
|
+
normalized[field] = { kind: actor.kind, id: actor.id };
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
for (const field of [
|
|
877
|
+
'title',
|
|
878
|
+
'body',
|
|
879
|
+
'subject',
|
|
880
|
+
'reason',
|
|
881
|
+
'response',
|
|
882
|
+
'note',
|
|
883
|
+
'details',
|
|
884
|
+
'description',
|
|
885
|
+
])
|
|
886
|
+
if (typeof normalized[field] === 'string') normalized[field] = normalized[field].trim();
|
|
887
|
+
for (const field of ['tags', 'topicIds'])
|
|
888
|
+
if (
|
|
889
|
+
field in normalized ||
|
|
890
|
+
operation.endsWith('.create') ||
|
|
891
|
+
['kudos.give', 'memos.send'].includes(operation)
|
|
892
|
+
)
|
|
893
|
+
normalized[field] = [...new Set((normalized[field] ?? []) as string[])].sort();
|
|
894
|
+
if (['kudos.give', 'memos.send', 'tasks.create'].includes(operation))
|
|
895
|
+
normalized.visibility ??= this.repository.config.defaultVisibility;
|
|
896
|
+
if (['notes.create', 'todos.create'].includes(operation)) normalized.owner ??= this.boundRef();
|
|
897
|
+
if (operation === 'tasks.create') normalized.assignee ??= this.boundRef();
|
|
898
|
+
if (['tasks.create', 'todos.create'].includes(operation)) normalized.priority ??= 3;
|
|
899
|
+
if (['replies.create', 'posts.create', 'posts.update'].includes(operation)) {
|
|
900
|
+
if (operation === 'replies.create') normalized.parentId ??= null;
|
|
901
|
+
if (operation === 'posts.update' && normalized.mentions === undefined) return normalized;
|
|
902
|
+
const mentions: ActorRef[] = [];
|
|
903
|
+
for (const supplied of (normalized.mentions ?? []) as ActorRef[]) {
|
|
904
|
+
let actor: AddressableActor;
|
|
905
|
+
try {
|
|
906
|
+
actor = await this.resolveActor(supplied, true);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
if (
|
|
909
|
+
error instanceof SynomemError &&
|
|
910
|
+
['AGENT_NOT_FOUND', 'INVALID_INPUT'].includes(error.code)
|
|
911
|
+
)
|
|
912
|
+
throw new SynomemError(
|
|
913
|
+
'INVALID_INPUT',
|
|
914
|
+
'Mention targets must be active and accessible.',
|
|
915
|
+
);
|
|
916
|
+
throw error;
|
|
917
|
+
}
|
|
918
|
+
if (!mentions.some((ref) => sameActor(ref, actor)))
|
|
919
|
+
mentions.push({ kind: actor.kind, id: actor.id });
|
|
920
|
+
}
|
|
921
|
+
normalized.mentions = mentions.sort((a, b) =>
|
|
922
|
+
`${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`),
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
return normalized;
|
|
926
|
+
}
|
|
927
|
+
private async authorizeReceipt(operation: string, result: unknown): Promise<void> {
|
|
928
|
+
if (operation.startsWith('replies.')) {
|
|
929
|
+
const reply = await this.getReply((result as ReplyRecord).id);
|
|
930
|
+
if (
|
|
931
|
+
operation === 'replies.delete' &&
|
|
932
|
+
!sameActor(reply.author, this.actor) &&
|
|
933
|
+
!this.canModerateRoot(await this.getItem(reply.rootId))
|
|
934
|
+
)
|
|
935
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Moderation authority was removed.');
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (operation === 'reactions.set') {
|
|
939
|
+
await this.reactionRoot((result as { targetId: string }).targetId);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const candidate = result as { record?: { event?: { id?: string } }; event?: { id?: string } };
|
|
943
|
+
const id = candidate.record?.event?.id ?? candidate.event?.id;
|
|
944
|
+
if (!id)
|
|
945
|
+
throw new SynomemError(
|
|
946
|
+
'IDEMPOTENCY_EXPIRED',
|
|
947
|
+
'The cached mutation result cannot be reconstructed.',
|
|
948
|
+
);
|
|
949
|
+
const record = await this.getItem(id);
|
|
950
|
+
const event = record.event;
|
|
951
|
+
if (operation.startsWith('notes.')) this.assertNoteOwner(record as NoteRecord);
|
|
952
|
+
else if (operation.startsWith('todos.')) this.assertTodoOwner(record as TodoRecord);
|
|
953
|
+
else if (operation.startsWith('tasks.') && operation !== 'tasks.create') {
|
|
954
|
+
if (['tasks.accept', 'tasks.reject'].includes(operation))
|
|
955
|
+
this.assertTaskAssignee(record as TaskRecord);
|
|
956
|
+
else this.assertTaskParticipant(record as TaskRecord);
|
|
957
|
+
if (
|
|
958
|
+
operation === 'tasks.overrideDecision' &&
|
|
959
|
+
(this.actor.kind !== 'human' ||
|
|
960
|
+
!(
|
|
961
|
+
this.administrative ||
|
|
962
|
+
((record as TaskRecord).event.assignee.kind === 'agent' &&
|
|
963
|
+
overseesAgent(
|
|
964
|
+
this.actor,
|
|
965
|
+
this.authority,
|
|
966
|
+
(record as TaskRecord).event.assignee.id,
|
|
967
|
+
)) ||
|
|
968
|
+
(event.actor.kind === 'agent' &&
|
|
969
|
+
overseesAgent(this.actor, this.authority, event.actor.id))
|
|
970
|
+
))
|
|
971
|
+
)
|
|
972
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only a human overseer may override a task.');
|
|
973
|
+
} else if (
|
|
974
|
+
operation.startsWith('posts.') &&
|
|
975
|
+
['posts.update', 'posts.archive'].includes(operation)
|
|
976
|
+
)
|
|
977
|
+
this.assertPostAuthor(record as PostRecord);
|
|
978
|
+
else if (operation === 'memos.read')
|
|
979
|
+
this.assertRecipient((record as MemoRecord).event.recipient, 'mark this memo read');
|
|
980
|
+
else if (operation === 'memos.archive')
|
|
981
|
+
this.assertRecipient((record as MemoRecord).event.recipient, 'archive this memo', true);
|
|
982
|
+
else if (
|
|
983
|
+
operation === 'kudos.acknowledge' &&
|
|
984
|
+
!sameActor(this.actor, (record as KudosRecord).event.recipient)
|
|
985
|
+
)
|
|
986
|
+
throw new SynomemError(
|
|
987
|
+
'ACKNOWLEDGMENT_FORBIDDEN',
|
|
988
|
+
'Only the recipient may acknowledge kudos.',
|
|
989
|
+
);
|
|
990
|
+
else if (operation === 'kudos.revoke' && !this.canManage(event.actor as ActorRef))
|
|
991
|
+
throw new SynomemError(
|
|
992
|
+
'REVOCATION_FORBIDDEN',
|
|
993
|
+
'Only the author or an authorized overseer may revoke kudos.',
|
|
994
|
+
);
|
|
995
|
+
for (const field of ['recipient', 'owner', 'assignee'] as const)
|
|
996
|
+
if (field in event) {
|
|
997
|
+
const ref = (event as unknown as Record<string, ActorRef>)[field]!;
|
|
998
|
+
await this.syncActor(ref);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
private personalWrite<T>(operation: () => Promise<T>): Promise<T> {
|
|
1002
|
+
this.checkAbort();
|
|
1003
|
+
if (!this.writeAllowed)
|
|
1004
|
+
throw new SynomemError('READ_ONLY', 'This binding cannot change personal state.');
|
|
1005
|
+
return this.repository.transaction(async () => {
|
|
1006
|
+
await this.resolveActor(this.boundRef(), true);
|
|
1007
|
+
return operation();
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
private async mutate<T>(operation: string, input: object, action: () => Promise<T>): Promise<T> {
|
|
1011
|
+
this.checkAbort();
|
|
1012
|
+
if (!this.writeAllowed)
|
|
1013
|
+
throw new SynomemError('READ_ONLY', 'This binding cannot write records.');
|
|
1014
|
+
const rawKey = (input as { idempotencyKey?: unknown }).idempotencyKey;
|
|
1015
|
+
if (
|
|
1016
|
+
rawKey !== undefined &&
|
|
1017
|
+
(typeof rawKey !== 'string' || !rawKey.trim() || rawKey.trim().length > 200)
|
|
1018
|
+
)
|
|
1019
|
+
throw new SynomemError('INVALID_INPUT', 'Idempotency keys must contain 1–200 characters.');
|
|
1020
|
+
const key = typeof rawKey === 'string' ? rawKey.trim() : undefined;
|
|
1021
|
+
const state = { ...(key ? { key } : {}), projections: new Set<string>() };
|
|
1022
|
+
const result = await this.mutationContext.run(state, () =>
|
|
1023
|
+
this.repository.transaction(async () => {
|
|
1024
|
+
if (this.actor.kind !== 'system') await this.resolveActor(this.boundRef(), true);
|
|
1025
|
+
const normalized = await this.normalizeMutation(operation, input);
|
|
1026
|
+
const requestHash = cursorFilter({ operation, request: normalized });
|
|
1027
|
+
const keyHash = key ? cursorFilter(key) : undefined;
|
|
1028
|
+
const prior = keyHash
|
|
1029
|
+
? await this.repository.getMutationReceipt(this.actor.kind, this.actor.id, keyHash)
|
|
1030
|
+
: undefined;
|
|
1031
|
+
if (prior) {
|
|
1032
|
+
if (prior.operation !== operation || prior.requestHash !== requestHash)
|
|
1033
|
+
throw new SynomemError(
|
|
1034
|
+
'IDEMPOTENCY_CONFLICT',
|
|
1035
|
+
'This key was already used for a different request.',
|
|
1036
|
+
);
|
|
1037
|
+
if (!prior.resultJson)
|
|
1038
|
+
throw new SynomemError(
|
|
1039
|
+
'IDEMPOTENCY_EXPIRED',
|
|
1040
|
+
'The original response expired. This request will not execute again.',
|
|
1041
|
+
);
|
|
1042
|
+
const response = JSON.parse(prior.resultJson) as T;
|
|
1043
|
+
await this.authorizeReceipt(operation, response);
|
|
1044
|
+
if (operation === 'replies.create' || operation === 'replies.delete') {
|
|
1045
|
+
const current = await this.getReply((response as ReplyRecord).id);
|
|
1046
|
+
if (current.deleted)
|
|
1047
|
+
return { ...response, body: undefined, mentions: undefined, deleted: true };
|
|
1048
|
+
}
|
|
1049
|
+
if (response && typeof response === 'object' && 'created' in response)
|
|
1050
|
+
return { ...response, created: false, deduplicated: true };
|
|
1051
|
+
return response;
|
|
1052
|
+
}
|
|
1053
|
+
if (
|
|
1054
|
+
key &&
|
|
1055
|
+
(await this.repository.getEventByIdempotency(this.actor.kind, this.actor.id, key))
|
|
1056
|
+
)
|
|
1057
|
+
throw new SynomemError(
|
|
1058
|
+
'IDEMPOTENCY_EXPIRED',
|
|
1059
|
+
'The original request receipt is unavailable. This key will not execute again.',
|
|
1060
|
+
);
|
|
1061
|
+
const lifecycleOperations = new Set([
|
|
1062
|
+
'kudos.acknowledge',
|
|
1063
|
+
'kudos.revoke',
|
|
1064
|
+
'memos.read',
|
|
1065
|
+
'memos.archive',
|
|
1066
|
+
'posts.archive',
|
|
1067
|
+
'posts.acknowledge',
|
|
1068
|
+
'posts.withdrawAcknowledgment',
|
|
1069
|
+
'notes.archive',
|
|
1070
|
+
'tasks.accept',
|
|
1071
|
+
'tasks.reject',
|
|
1072
|
+
'tasks.complete',
|
|
1073
|
+
'tasks.reopen',
|
|
1074
|
+
'tasks.cancel',
|
|
1075
|
+
'todos.complete',
|
|
1076
|
+
'todos.reopen',
|
|
1077
|
+
'todos.cancel',
|
|
1078
|
+
'todos.archive',
|
|
1079
|
+
]);
|
|
1080
|
+
if (lifecycleOperations.has(operation)) {
|
|
1081
|
+
const supplied = (input as { expectedVersion?: unknown }).expectedVersion;
|
|
1082
|
+
if (!Number.isSafeInteger(supplied) || (supplied as number) < 1)
|
|
1083
|
+
throw new SynomemError(
|
|
1084
|
+
'INVALID_INPUT',
|
|
1085
|
+
'expectedVersion must be the current positive lifecycleVersion.',
|
|
1086
|
+
);
|
|
1087
|
+
const rootId = normalized[
|
|
1088
|
+
(operation.startsWith('kudos.')
|
|
1089
|
+
? 'kudos'
|
|
1090
|
+
: operation.split('.')[0]!.replace(/s$/, '')) + 'Id'
|
|
1091
|
+
] as string;
|
|
1092
|
+
const current = await this.getItem(rootId);
|
|
1093
|
+
await this.authorizeReceipt(operation, current);
|
|
1094
|
+
if (current.lifecycleVersion !== supplied)
|
|
1095
|
+
throw new SynomemError(
|
|
1096
|
+
'REVISION_CONFLICT',
|
|
1097
|
+
'The record changed. Refresh before applying this action.',
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
const response = await action();
|
|
1101
|
+
if (keyHash) {
|
|
1102
|
+
const resultJson = JSON.stringify(response);
|
|
1103
|
+
if (Buffer.byteLength(resultJson) > 262144)
|
|
1104
|
+
throw new SynomemError(
|
|
1105
|
+
'INVALID_INPUT',
|
|
1106
|
+
'The mutation response exceeds the receipt budget.',
|
|
1107
|
+
);
|
|
1108
|
+
const eventId = (
|
|
1109
|
+
key
|
|
1110
|
+
? await this.repository.getEventByIdempotency(this.actor.kind, this.actor.id, key)
|
|
1111
|
+
: undefined
|
|
1112
|
+
)?.id;
|
|
1113
|
+
await this.repository.insertMutationReceipt(this.actor.kind, this.actor.id, keyHash, {
|
|
1114
|
+
operation,
|
|
1115
|
+
requestHash,
|
|
1116
|
+
resultJson,
|
|
1117
|
+
...(eventId ? { eventId } : {}),
|
|
1118
|
+
createdAt: this.now(),
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
return response;
|
|
1122
|
+
}),
|
|
1123
|
+
);
|
|
1124
|
+
for (const id of state.projections) await this.projectionWriter.syncAgent(id);
|
|
1125
|
+
return result;
|
|
1126
|
+
}
|
|
335
1127
|
private now(): string {
|
|
336
1128
|
return this.clock().toISOString();
|
|
337
1129
|
}
|
|
338
|
-
|
|
339
1130
|
private nextId(): string {
|
|
340
1131
|
return this.idGenerator();
|
|
341
1132
|
}
|
|
342
|
-
|
|
343
1133
|
private eventBase(aggregateId: string, aggregateVersion: number, id = this.nextId()) {
|
|
344
1134
|
return {
|
|
345
|
-
schemaVersion:
|
|
1135
|
+
schemaVersion: 2 as const,
|
|
1136
|
+
...(this.mutationContext.getStore()?.key
|
|
1137
|
+
? { idempotencyKey: this.mutationContext.getStore()!.key }
|
|
1138
|
+
: {}),
|
|
346
1139
|
id,
|
|
347
1140
|
workspaceId: this.repository.config.workspaceId,
|
|
348
1141
|
aggregateId,
|
|
@@ -351,11 +1144,9 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
351
1144
|
actor: this.actor,
|
|
352
1145
|
};
|
|
353
1146
|
}
|
|
354
|
-
|
|
355
1147
|
protected checkAbort(): void {
|
|
356
1148
|
this.signal?.throwIfAborted();
|
|
357
1149
|
}
|
|
358
|
-
|
|
359
1150
|
private validate<T>(operation: () => T): T {
|
|
360
1151
|
try {
|
|
361
1152
|
return operation();
|
|
@@ -363,7 +1154,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
363
1154
|
throw asSynomemError(error);
|
|
364
1155
|
}
|
|
365
1156
|
}
|
|
366
|
-
|
|
367
1157
|
private async createAgent(input: CreateAgentInput): Promise<AgentProfile> {
|
|
368
1158
|
this.checkAbort();
|
|
369
1159
|
await this.repository.assertEventCompatibility();
|
|
@@ -409,7 +1199,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
409
1199
|
await this.projectionWriter.syncAgent(profile.id);
|
|
410
1200
|
return profile;
|
|
411
1201
|
}
|
|
412
|
-
|
|
413
1202
|
private async updateAgent(idOrAlias: string, changes: UpdateAgentInput): Promise<AgentProfile> {
|
|
414
1203
|
this.checkAbort();
|
|
415
1204
|
await this.repository.assertEventCompatibility();
|
|
@@ -467,7 +1256,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
467
1256
|
await this.projectionWriter.syncAgent(updated.id);
|
|
468
1257
|
return updated;
|
|
469
1258
|
}
|
|
470
|
-
|
|
471
1259
|
/**
|
|
472
1260
|
* Archiving stops an agent acting without erasing it.
|
|
473
1261
|
*
|
|
@@ -499,21 +1287,18 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
499
1287
|
await this.projectionWriter.syncAgent(updated.id);
|
|
500
1288
|
return updated;
|
|
501
1289
|
}
|
|
502
|
-
|
|
503
1290
|
/** Adds aliases without disturbing the ones already there. */
|
|
504
1291
|
private async addAgentAliases(idOrAlias: string, add: string[]): Promise<AgentProfile> {
|
|
505
1292
|
const existing = await this.getAgent(idOrAlias);
|
|
506
1293
|
const merged = [...new Set([...(existing.aliases ?? []), ...add])].sort();
|
|
507
1294
|
return await this.updateAgent(existing.id, { aliases: merged });
|
|
508
1295
|
}
|
|
509
|
-
|
|
510
1296
|
private async removeAgentAliases(idOrAlias: string, remove: string[]): Promise<AgentProfile> {
|
|
511
1297
|
const existing = await this.getAgent(idOrAlias);
|
|
512
1298
|
const drop = new Set(remove.map((alias) => alias.trim().toLowerCase()));
|
|
513
1299
|
const kept = (existing.aliases ?? []).filter((alias) => !drop.has(alias));
|
|
514
1300
|
return await this.updateAgent(existing.id, { aliases: kept });
|
|
515
1301
|
}
|
|
516
|
-
|
|
517
1302
|
private async getAgent(idOrAlias: string): Promise<AgentProfile> {
|
|
518
1303
|
this.checkAbort();
|
|
519
1304
|
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
@@ -521,12 +1306,10 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
521
1306
|
if (!profile) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
522
1307
|
return profile;
|
|
523
1308
|
}
|
|
524
|
-
|
|
525
1309
|
private async listAgents(): Promise<AgentProfile[]> {
|
|
526
1310
|
this.checkAbort();
|
|
527
1311
|
return await this.repository.listAgents();
|
|
528
1312
|
}
|
|
529
|
-
|
|
530
1313
|
/**
|
|
531
1314
|
* Resolves a name without ever choosing between equally valid answers.
|
|
532
1315
|
*
|
|
@@ -545,7 +1328,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
545
1328
|
candidates: resolved.candidates,
|
|
546
1329
|
};
|
|
547
1330
|
}
|
|
548
|
-
|
|
549
1331
|
private async agentDirectory(): Promise<AgentDirectoryEntry[]> {
|
|
550
1332
|
this.checkAbort();
|
|
551
1333
|
const profiles = await this.repository.listAgents();
|
|
@@ -558,12 +1340,10 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
558
1340
|
}
|
|
559
1341
|
return entries;
|
|
560
1342
|
}
|
|
561
|
-
|
|
562
1343
|
private async listRuntimeBindings(idOrAlias: string): Promise<AgentRuntimeBinding[]> {
|
|
563
1344
|
const profile = await this.getAgent(idOrAlias);
|
|
564
1345
|
return await this.repository.listRuntimeBindings(profile.id);
|
|
565
1346
|
}
|
|
566
|
-
|
|
567
1347
|
/**
|
|
568
1348
|
* Records where an agent runs. Re-binding the same runtime and profile
|
|
569
1349
|
* updates the claim in place rather than accumulating duplicates, because a
|
|
@@ -592,12 +1372,10 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
592
1372
|
if (!binding) throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
|
|
593
1373
|
return binding;
|
|
594
1374
|
}
|
|
595
|
-
|
|
596
1375
|
private async unbindRuntime(bindingId: string): Promise<boolean> {
|
|
597
1376
|
this.checkAbort();
|
|
598
1377
|
return await this.repository.unbindRuntime(bindingId);
|
|
599
1378
|
}
|
|
600
|
-
|
|
601
1379
|
/* ---------------------------------------------------------------- topics *
|
|
602
1380
|
* A topic is a controlled, reusable subject a record can be filed under —
|
|
603
1381
|
* one canonical display name and a set of aliases, so a stable "Synomem"
|
|
@@ -606,7 +1384,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
606
1384
|
* an administrator renames or archives it, matching how an agent's own
|
|
607
1385
|
* identity is managed.
|
|
608
1386
|
*/
|
|
609
|
-
|
|
610
1387
|
private async createTopic(input: CreateTopicInput): Promise<Topic> {
|
|
611
1388
|
this.checkAbort();
|
|
612
1389
|
await this.repository.assertEventCompatibility();
|
|
@@ -641,7 +1418,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
641
1418
|
});
|
|
642
1419
|
return topic;
|
|
643
1420
|
}
|
|
644
|
-
|
|
645
1421
|
private async updateTopicRecord(idOrAlias: string, changes: UpdateTopicInput): Promise<Topic> {
|
|
646
1422
|
this.checkAbort();
|
|
647
1423
|
await this.repository.assertEventCompatibility();
|
|
@@ -683,7 +1459,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
683
1459
|
});
|
|
684
1460
|
return updated;
|
|
685
1461
|
}
|
|
686
|
-
|
|
687
1462
|
private async setTopicStatus(idOrAlias: string, status: 'active' | 'archived'): Promise<Topic> {
|
|
688
1463
|
this.checkAbort();
|
|
689
1464
|
await this.repository.assertEventCompatibility();
|
|
@@ -704,7 +1479,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
704
1479
|
});
|
|
705
1480
|
return updated;
|
|
706
1481
|
}
|
|
707
|
-
|
|
708
1482
|
private async getTopicRecord(idOrAlias: string): Promise<Topic> {
|
|
709
1483
|
this.checkAbort();
|
|
710
1484
|
this.validate(() => topicLookupSchema.parse(idOrAlias));
|
|
@@ -712,20 +1486,17 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
712
1486
|
if (!topic) throw new SynomemError('TOPIC_NOT_FOUND', `Unknown topic: ${idOrAlias}`);
|
|
713
1487
|
return topic;
|
|
714
1488
|
}
|
|
715
|
-
|
|
716
1489
|
private async listTopicsRecord(input: TopicListInput): Promise<Topic[]> {
|
|
717
1490
|
this.checkAbort();
|
|
718
1491
|
const parsed = this.validate(() => topicListInputSchema.parse(input));
|
|
719
1492
|
return await this.repository.listTopics(parsed.status);
|
|
720
1493
|
}
|
|
721
|
-
|
|
722
1494
|
private async resolveTopicRecord(query: string): Promise<TopicResolution> {
|
|
723
1495
|
this.checkAbort();
|
|
724
1496
|
const trimmed = this.validate(() => topicLookupSchema.parse(query));
|
|
725
1497
|
const resolved = await this.repository.resolveTopic(trimmed);
|
|
726
1498
|
return { query: trimmed, ...resolved };
|
|
727
1499
|
}
|
|
728
|
-
|
|
729
1500
|
/**
|
|
730
1501
|
* Every topic in `topicIds` must already exist and be active — a record
|
|
731
1502
|
* cannot be filed under a subject that does not exist, or one somebody
|
|
@@ -739,7 +1510,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
739
1510
|
}
|
|
740
1511
|
}
|
|
741
1512
|
}
|
|
742
|
-
|
|
743
1513
|
private async giveKudos(input: GiveKudosInput): Promise<GiveKudosResult> {
|
|
744
1514
|
this.checkAbort();
|
|
745
1515
|
await this.repository.assertEventCompatibility();
|
|
@@ -750,21 +1520,20 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
750
1520
|
}),
|
|
751
1521
|
);
|
|
752
1522
|
await this.assertTopicsExist(parsed.topicIds);
|
|
753
|
-
const recipient = await this.
|
|
1523
|
+
const recipient = await this.resolveActor(parsed.recipient, true);
|
|
754
1524
|
if (!recipient) {
|
|
755
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.
|
|
1525
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipient?.id}`);
|
|
756
1526
|
}
|
|
757
1527
|
if (
|
|
758
1528
|
!this.repository.config.allowSelfAwards &&
|
|
759
1529
|
this.actor.kind !== 'human' &&
|
|
760
|
-
this.actor
|
|
1530
|
+
sameActor(this.actor, recipient)
|
|
761
1531
|
) {
|
|
762
1532
|
throw new SynomemError(
|
|
763
1533
|
'SELF_AWARD_FORBIDDEN',
|
|
764
1534
|
'Non-human actors cannot award kudos to a matching agent identity.',
|
|
765
1535
|
);
|
|
766
1536
|
}
|
|
767
|
-
|
|
768
1537
|
const outcome = await this.repository.transaction(async () => {
|
|
769
1538
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'kudos.given');
|
|
770
1539
|
if (prior?.type === 'kudos.given') return { event: prior, created: false };
|
|
@@ -772,7 +1541,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
772
1541
|
const event: KudosGivenEvent = {
|
|
773
1542
|
...this.eventBase(id, 1, id),
|
|
774
1543
|
type: 'kudos.given',
|
|
775
|
-
|
|
1544
|
+
recipient: { kind: recipient.kind, id: recipient.id },
|
|
776
1545
|
recipientDisplayName: recipient.displayName,
|
|
777
1546
|
title: parsed.title,
|
|
778
1547
|
reason: parsed.reason,
|
|
@@ -787,48 +1556,47 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
787
1556
|
await this.repository.insertEvent(event);
|
|
788
1557
|
return { event, created: true };
|
|
789
1558
|
});
|
|
790
|
-
if (outcome.created) await this.
|
|
1559
|
+
if (outcome.created) await this.syncActor(recipient);
|
|
791
1560
|
const record = await this.getKudosRecord(outcome.event.id);
|
|
792
1561
|
return { record, created: outcome.created, deduplicated: !outcome.created };
|
|
793
1562
|
}
|
|
794
|
-
|
|
795
1563
|
private async getKudosRecord(id: string): Promise<KudosRecord> {
|
|
796
1564
|
await this.requireVisibleItem(id, 'kudos');
|
|
797
1565
|
const record = recordsFromEvents(await this.repository.getReadableSynomemEvents(id))[0];
|
|
798
1566
|
if (!record) throw new SynomemError('KUDOS_NOT_FOUND', `Unknown kudos: ${id}`);
|
|
799
|
-
return record;
|
|
1567
|
+
return this.decorateRecord(record);
|
|
800
1568
|
}
|
|
801
|
-
|
|
802
1569
|
private async getKudos(id: string): Promise<KudosRecord> {
|
|
803
1570
|
this.checkAbort();
|
|
804
1571
|
return await this.getKudosRecord(id);
|
|
805
1572
|
}
|
|
806
|
-
|
|
807
1573
|
private async listKudos(input: KudosListInput): Promise<Page<KudosSummary>> {
|
|
808
1574
|
this.checkAbort();
|
|
809
1575
|
const filters = this.validate(() => listInputSchema.parse(input));
|
|
810
|
-
const recipient = filters.
|
|
811
|
-
? await this.
|
|
1576
|
+
const recipient = filters.recipient?.id
|
|
1577
|
+
? await this.resolveActor(filters.recipient)
|
|
812
1578
|
: undefined;
|
|
813
|
-
if (filters.
|
|
814
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.
|
|
1579
|
+
if (filters.recipient?.id && !recipient) {
|
|
1580
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipient?.id}`);
|
|
815
1581
|
}
|
|
816
1582
|
return await this.repository.listKudosSummaries(
|
|
817
1583
|
{
|
|
818
1584
|
...filters,
|
|
819
|
-
...(recipient ? {
|
|
1585
|
+
...(recipient ? { recipient: { kind: recipient.kind, id: recipient.id } } : {}),
|
|
820
1586
|
},
|
|
821
1587
|
this.actor,
|
|
822
1588
|
);
|
|
823
1589
|
}
|
|
824
|
-
|
|
825
1590
|
private async listKudosChanges(input: KudosChangesInput): Promise<ChangePage> {
|
|
826
1591
|
this.checkAbort();
|
|
827
1592
|
const parsed = this.validate(() => changesInputSchema.parse(input));
|
|
828
1593
|
return await this.repository.listKudosChanges(parsed.after, parsed.limit, this.actor);
|
|
829
1594
|
}
|
|
830
|
-
|
|
831
|
-
|
|
1595
|
+
private async acknowledgeKudos(input: {
|
|
1596
|
+
expectedVersion: number;
|
|
1597
|
+
kudosId: string;
|
|
1598
|
+
note?: string;
|
|
1599
|
+
}): Promise<KudosRecord> {
|
|
832
1600
|
this.checkAbort();
|
|
833
1601
|
await this.repository.assertEventCompatibility();
|
|
834
1602
|
if (input.note !== undefined && (input.note.trim().length < 1 || input.note.length > 2000)) {
|
|
@@ -838,27 +1606,26 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
838
1606
|
if (record.acknowledgment) return record;
|
|
839
1607
|
if (record.revocation)
|
|
840
1608
|
throw new SynomemError('INVALID_INPUT', 'Revoked kudos cannot be acknowledged.');
|
|
841
|
-
const isRecipient =
|
|
842
|
-
|
|
843
|
-
if (!this.administrative && !isRecipient) {
|
|
1609
|
+
const isRecipient = sameActor(this.actor, record.event.recipient);
|
|
1610
|
+
if (!isRecipient) {
|
|
844
1611
|
throw new SynomemError(
|
|
845
1612
|
'ACKNOWLEDGMENT_FORBIDDEN',
|
|
846
|
-
'Only the recipient
|
|
1613
|
+
'Only the recipient may acknowledge kudos.',
|
|
847
1614
|
);
|
|
848
1615
|
}
|
|
849
1616
|
const event: KudosAcknowledgedEvent = {
|
|
850
1617
|
...this.eventBase(record.event.id, 2),
|
|
851
1618
|
type: 'kudos.acknowledged',
|
|
852
1619
|
kudosId: record.event.id,
|
|
853
|
-
|
|
1620
|
+
recipient: record.event.recipient,
|
|
854
1621
|
...(input.note ? { note: input.note.trim() } : {}),
|
|
855
1622
|
};
|
|
856
1623
|
await this.repository.transaction(() => this.repository.insertEvent(event));
|
|
857
|
-
await this.
|
|
1624
|
+
await this.syncActor(record.event.recipient);
|
|
858
1625
|
return await this.getKudosRecord(input.kudosId);
|
|
859
1626
|
}
|
|
860
|
-
|
|
861
1627
|
private async revokeKudos(input: {
|
|
1628
|
+
expectedVersion: number;
|
|
862
1629
|
kudosId: string;
|
|
863
1630
|
reason: string;
|
|
864
1631
|
administrative?: boolean;
|
|
@@ -880,24 +1647,24 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
880
1647
|
);
|
|
881
1648
|
}
|
|
882
1649
|
const administrative = this.administrative && !isOriginalActor;
|
|
883
|
-
if (!
|
|
1650
|
+
if (!this.canManage(record.event.actor as ActorRef)) {
|
|
884
1651
|
throw new SynomemError(
|
|
885
1652
|
'REVOCATION_FORBIDDEN',
|
|
886
|
-
'Only the
|
|
1653
|
+
'Only the author or an authorized overseer may revoke kudos.',
|
|
887
1654
|
);
|
|
888
1655
|
}
|
|
889
1656
|
const event: KudosRevokedEvent = {
|
|
890
1657
|
...this.eventBase(record.event.id, record.acknowledgment ? 3 : 2),
|
|
1658
|
+
...this.intervention(record.event.actor as ActorRef, reason),
|
|
891
1659
|
type: 'kudos.revoked',
|
|
892
1660
|
kudosId: record.event.id,
|
|
893
1661
|
reason,
|
|
894
1662
|
mode: administrative && !isOriginalActor ? 'administrative' : 'actor-requested',
|
|
895
1663
|
};
|
|
896
1664
|
await this.repository.transaction(() => this.repository.insertEvent(event));
|
|
897
|
-
await this.
|
|
1665
|
+
await this.syncActor(record.event.recipient);
|
|
898
1666
|
return await this.getKudosRecord(input.kudosId);
|
|
899
1667
|
}
|
|
900
|
-
|
|
901
1668
|
private async priorMutation(
|
|
902
1669
|
idempotencyKey: string | undefined,
|
|
903
1670
|
expectedType: SynomemEvent['type'],
|
|
@@ -916,19 +1683,20 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
916
1683
|
}
|
|
917
1684
|
return prior;
|
|
918
1685
|
}
|
|
919
|
-
|
|
920
1686
|
private canViewItem(summary: ItemSummary): boolean {
|
|
1687
|
+
if (summary.kind === 'note' || summary.kind === 'todo')
|
|
1688
|
+
return !!summary.owner && this.canManage(summary.owner);
|
|
921
1689
|
return (
|
|
922
1690
|
this.administrative ||
|
|
923
1691
|
summary.visibility !== 'private' ||
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1692
|
+
[summary.actor, summary.recipient, summary.assignee].some(
|
|
1693
|
+
(ref) =>
|
|
1694
|
+
!!ref &&
|
|
1695
|
+
(sameActor(ref, this.actor) ||
|
|
1696
|
+
(ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id))),
|
|
1697
|
+
)
|
|
929
1698
|
);
|
|
930
1699
|
}
|
|
931
|
-
|
|
932
1700
|
private async requireVisibleItem(id: string, kind: ItemSummary['kind']): Promise<ItemSummary> {
|
|
933
1701
|
const summary = await this.repository.getItemSummary(id);
|
|
934
1702
|
if (!summary || summary.kind !== kind) {
|
|
@@ -950,27 +1718,24 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
950
1718
|
}
|
|
951
1719
|
return summary;
|
|
952
1720
|
}
|
|
953
|
-
|
|
954
1721
|
private async getMemoRecord(id: string): Promise<MemoRecord> {
|
|
955
1722
|
await this.requireVisibleItem(id, 'memo');
|
|
956
1723
|
const record = memoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
957
1724
|
if (!record) throw new SynomemError('MEMO_NOT_FOUND', `Unknown memo: ${id}`);
|
|
958
|
-
return record;
|
|
1725
|
+
return this.decorateRecord(record);
|
|
959
1726
|
}
|
|
960
|
-
|
|
961
1727
|
private async getMemo(id: string): Promise<MemoRecord> {
|
|
962
1728
|
this.checkAbort();
|
|
963
1729
|
return await this.getMemoRecord(id);
|
|
964
1730
|
}
|
|
965
|
-
|
|
966
1731
|
private async sendMemo(input: SendMemoInput): Promise<SendMemoResult> {
|
|
967
1732
|
this.checkAbort();
|
|
968
1733
|
await this.repository.assertEventCompatibility();
|
|
969
1734
|
const parsed = this.validate(() => sendMemoSchema.parse(input));
|
|
970
1735
|
await this.assertTopicsExist(parsed.topicIds);
|
|
971
|
-
const recipient = await this.
|
|
1736
|
+
const recipient = await this.resolveActor(parsed.recipient, true);
|
|
972
1737
|
if (!recipient)
|
|
973
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.
|
|
1738
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipient?.id}`);
|
|
974
1739
|
const outcome = await this.repository.transaction(async () => {
|
|
975
1740
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'memo.sent');
|
|
976
1741
|
if (prior?.type === 'memo.sent') return { id: prior.id, created: false };
|
|
@@ -978,7 +1743,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
978
1743
|
const event: SynomemEvent = {
|
|
979
1744
|
...this.eventBase(id, 1, id),
|
|
980
1745
|
type: 'memo.sent',
|
|
981
|
-
|
|
1746
|
+
recipient: { kind: recipient.kind, id: recipient.id },
|
|
982
1747
|
recipientDisplayName: recipient.displayName,
|
|
983
1748
|
subject: parsed.subject,
|
|
984
1749
|
body: parsed.body,
|
|
@@ -992,29 +1757,24 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
992
1757
|
await this.repository.insertEvent(event);
|
|
993
1758
|
return { id, created: true };
|
|
994
1759
|
});
|
|
995
|
-
if (outcome.created) await this.
|
|
1760
|
+
if (outcome.created) await this.syncActor(recipient);
|
|
996
1761
|
return {
|
|
997
1762
|
record: await this.getMemoRecord(outcome.id),
|
|
998
1763
|
created: outcome.created,
|
|
999
1764
|
deduplicated: !outcome.created,
|
|
1000
1765
|
};
|
|
1001
1766
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
!this.administrative &&
|
|
1006
|
-
!(this.actor.kind === 'agent' && this.actor.id === recipientAgentId)
|
|
1007
|
-
) {
|
|
1008
|
-
throw new SynomemError(
|
|
1009
|
-
'MUTATION_FORBIDDEN',
|
|
1010
|
-
`Only the recipient agent or a human administrator may ${operation}.`,
|
|
1011
|
-
);
|
|
1012
|
-
}
|
|
1767
|
+
private assertRecipient(recipient: ActorRef, operation: string, oversight = false): void {
|
|
1768
|
+
if (!(oversight ? this.canManage(recipient) : sameActor(this.actor, recipient)))
|
|
1769
|
+
throw new SynomemError('MUTATION_FORBIDDEN', `Only the recipient may ${operation}.`);
|
|
1013
1770
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1771
|
+
private async readMemo(input: {
|
|
1772
|
+
expectedVersion: number;
|
|
1773
|
+
memoId: string;
|
|
1774
|
+
idempotencyKey?: string;
|
|
1775
|
+
}): Promise<MemoRecord> {
|
|
1016
1776
|
const record = await this.getMemoRecord(input.memoId);
|
|
1017
|
-
this.assertRecipient(record.event.
|
|
1777
|
+
this.assertRecipient(record.event.recipient, 'mark this memo read');
|
|
1018
1778
|
if (record.read) return record;
|
|
1019
1779
|
await this.repository.transaction(async () => {
|
|
1020
1780
|
const prior = await this.priorMutation(input.idempotencyKey, 'memo.read');
|
|
@@ -1026,53 +1786,62 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1026
1786
|
),
|
|
1027
1787
|
type: 'memo.read',
|
|
1028
1788
|
memoId: record.event.id,
|
|
1029
|
-
|
|
1789
|
+
recipient: record.event.recipient,
|
|
1030
1790
|
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
1031
1791
|
};
|
|
1032
1792
|
await this.repository.insertEvent(event);
|
|
1033
1793
|
});
|
|
1034
|
-
await this.
|
|
1794
|
+
await this.syncActor(record.event.recipient);
|
|
1035
1795
|
return await this.getMemoRecord(input.memoId);
|
|
1036
1796
|
}
|
|
1037
|
-
|
|
1038
1797
|
private async archiveMemo(input: {
|
|
1798
|
+
expectedVersion: number;
|
|
1039
1799
|
memoId: string;
|
|
1040
1800
|
idempotencyKey?: string;
|
|
1041
1801
|
}): Promise<MemoRecord> {
|
|
1042
1802
|
const record = await this.getMemoRecord(input.memoId);
|
|
1043
|
-
this.assertRecipient(record.event.
|
|
1803
|
+
this.assertRecipient(record.event.recipient, 'archive this memo', true);
|
|
1044
1804
|
if (record.archived) return record;
|
|
1045
1805
|
await this.repository.transaction(async () => {
|
|
1046
1806
|
const prior = await this.priorMutation(input.idempotencyKey, 'memo.archived');
|
|
1047
1807
|
if (prior) return;
|
|
1048
1808
|
const event: SynomemEvent = {
|
|
1809
|
+
...this.intervention(record.event.recipient),
|
|
1049
1810
|
...this.eventBase(
|
|
1050
1811
|
record.event.id,
|
|
1051
1812
|
await this.repository.nextAggregateVersion(record.event.id),
|
|
1052
1813
|
),
|
|
1053
1814
|
type: 'memo.archived',
|
|
1054
1815
|
memoId: record.event.id,
|
|
1055
|
-
|
|
1816
|
+
recipient: record.event.recipient,
|
|
1056
1817
|
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
1057
1818
|
};
|
|
1058
1819
|
await this.repository.insertEvent(event);
|
|
1059
1820
|
});
|
|
1060
|
-
await this.
|
|
1821
|
+
await this.syncActor(record.event.recipient);
|
|
1061
1822
|
return await this.getMemoRecord(input.memoId);
|
|
1062
1823
|
}
|
|
1063
|
-
|
|
1064
1824
|
private async getPostRecord(id: string): Promise<PostRecord> {
|
|
1065
1825
|
await this.requireVisibleItem(id, 'post');
|
|
1066
1826
|
const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
1067
1827
|
if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
|
|
1068
|
-
return record;
|
|
1828
|
+
return this.decorateRecord(record);
|
|
1069
1829
|
}
|
|
1070
|
-
|
|
1071
1830
|
private async getPost(id: string): Promise<PostRecord> {
|
|
1072
1831
|
this.checkAbort();
|
|
1073
1832
|
return await this.getPostRecord(id);
|
|
1074
1833
|
}
|
|
1075
|
-
|
|
1834
|
+
private async canonicalPostMentions(input: ActorRef[]): Promise<ActorRef[]> {
|
|
1835
|
+
const mentions: ActorRef[] = [];
|
|
1836
|
+
for (const reference of input) {
|
|
1837
|
+
const actor = await this.resolveActor(reference, true);
|
|
1838
|
+
const ref = { kind: actor.kind, id: actor.id };
|
|
1839
|
+
if (!mentions.some((mention) => sameActor(mention, ref))) mentions.push(ref);
|
|
1840
|
+
}
|
|
1841
|
+
return mentions.sort((left, right) =>
|
|
1842
|
+
`${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`),
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1076
1845
|
private async createPost(input: CreatePostInput): Promise<{
|
|
1077
1846
|
record: PostRecord;
|
|
1078
1847
|
created: boolean;
|
|
@@ -1082,11 +1851,9 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1082
1851
|
await this.repository.assertEventCompatibility();
|
|
1083
1852
|
const parsed = this.validate(() => createPostSchema.parse(input));
|
|
1084
1853
|
await this.assertTopicsExist(parsed.topicIds);
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
if (parsed.replyTo) await this.requireVisibleItem(parsed.replyTo, 'post');
|
|
1089
|
-
|
|
1854
|
+
const mentions = parsed.mentions
|
|
1855
|
+
? await this.canonicalPostMentions(parsed.mentions)
|
|
1856
|
+
: undefined;
|
|
1090
1857
|
const outcome = await this.repository.transaction(async () => {
|
|
1091
1858
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
|
|
1092
1859
|
if (prior?.type === 'post.created') return { id: prior.id, created: false };
|
|
@@ -1096,9 +1863,9 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1096
1863
|
type: 'post.created',
|
|
1097
1864
|
title: parsed.title,
|
|
1098
1865
|
body: parsed.body,
|
|
1866
|
+
mentions: mentions ?? [],
|
|
1099
1867
|
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
1100
1868
|
topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
|
|
1101
|
-
...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
|
|
1102
1869
|
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
1103
1870
|
...(parsed.source ? { source: parsed.source } : {}),
|
|
1104
1871
|
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
@@ -1112,19 +1879,25 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1112
1879
|
deduplicated: !outcome.created,
|
|
1113
1880
|
};
|
|
1114
1881
|
}
|
|
1115
|
-
|
|
1116
1882
|
/** Only the author edits a post. Everyone else responds to it. */
|
|
1117
1883
|
private assertPostAuthor(record: PostRecord): void {
|
|
1118
|
-
if (
|
|
1884
|
+
if (
|
|
1885
|
+
this.administrative ||
|
|
1886
|
+
(record.event.actor.kind === 'agent' &&
|
|
1887
|
+
overseesAgent(this.actor, this.authority, record.event.actor.id))
|
|
1888
|
+
)
|
|
1889
|
+
return;
|
|
1119
1890
|
if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
|
|
1120
1891
|
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
|
|
1121
1892
|
}
|
|
1122
1893
|
}
|
|
1123
|
-
|
|
1124
1894
|
private async updatePost(input: UpdatePostInput): Promise<PostRecord> {
|
|
1125
1895
|
this.checkAbort();
|
|
1126
1896
|
const parsed = this.validate(() => updatePostSchema.parse(input));
|
|
1127
1897
|
await this.assertTopicsExist(parsed.topicIds);
|
|
1898
|
+
const mentions = parsed.mentions
|
|
1899
|
+
? await this.canonicalPostMentions(parsed.mentions)
|
|
1900
|
+
: undefined;
|
|
1128
1901
|
const record = await this.getPostRecord(parsed.postId);
|
|
1129
1902
|
this.assertPostAuthor(record);
|
|
1130
1903
|
if (record.status === 'archived') {
|
|
@@ -1138,11 +1911,13 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1138
1911
|
}
|
|
1139
1912
|
await this.repository.transaction(async () => {
|
|
1140
1913
|
const event: SynomemEvent = {
|
|
1914
|
+
...this.intervention(record.event.actor as ActorRef),
|
|
1141
1915
|
...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
|
|
1142
1916
|
type: 'post.edited',
|
|
1143
1917
|
postId: parsed.postId,
|
|
1144
1918
|
title: parsed.title ?? record.title,
|
|
1145
1919
|
body: parsed.body ?? record.body,
|
|
1920
|
+
mentions: mentions ?? record.mentions ?? [],
|
|
1146
1921
|
tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
|
|
1147
1922
|
topicIds: [...new Set(parsed.topicIds ?? record.topicIds ?? [])].sort(),
|
|
1148
1923
|
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
@@ -1151,8 +1926,8 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1151
1926
|
});
|
|
1152
1927
|
return await this.getPostRecord(parsed.postId);
|
|
1153
1928
|
}
|
|
1154
|
-
|
|
1155
1929
|
private async archivePost(input: {
|
|
1930
|
+
expectedVersion: number;
|
|
1156
1931
|
postId: string;
|
|
1157
1932
|
reason?: string;
|
|
1158
1933
|
idempotencyKey?: string;
|
|
@@ -1163,6 +1938,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1163
1938
|
if (record.status !== 'archived') {
|
|
1164
1939
|
await this.repository.transaction(async () => {
|
|
1165
1940
|
const event: SynomemEvent = {
|
|
1941
|
+
...this.intervention(record.event.actor as ActorRef),
|
|
1166
1942
|
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
1167
1943
|
type: 'post.archived',
|
|
1168
1944
|
postId: input.postId,
|
|
@@ -1174,8 +1950,8 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1174
1950
|
}
|
|
1175
1951
|
return await this.getPostRecord(input.postId);
|
|
1176
1952
|
}
|
|
1177
|
-
|
|
1178
1953
|
private async acknowledgePost(input: {
|
|
1954
|
+
expectedVersion: number;
|
|
1179
1955
|
postId: string;
|
|
1180
1956
|
note?: string;
|
|
1181
1957
|
idempotencyKey?: string;
|
|
@@ -1201,10 +1977,11 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1201
1977
|
}
|
|
1202
1978
|
return await this.getPostRecord(input.postId);
|
|
1203
1979
|
}
|
|
1204
|
-
|
|
1205
1980
|
private async withdrawPostAcknowledgment(input: {
|
|
1981
|
+
expectedVersion: number;
|
|
1206
1982
|
postId: string;
|
|
1207
1983
|
reason?: string;
|
|
1984
|
+
idempotencyKey?: string;
|
|
1208
1985
|
}): Promise<PostRecord> {
|
|
1209
1986
|
this.checkAbort();
|
|
1210
1987
|
const record = await this.getPostRecord(input.postId);
|
|
@@ -1224,7 +2001,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1224
2001
|
}
|
|
1225
2002
|
return await this.getPostRecord(input.postId);
|
|
1226
2003
|
}
|
|
1227
|
-
|
|
1228
2004
|
private async postRoster(postId: string): Promise<PostRoster> {
|
|
1229
2005
|
this.checkAbort();
|
|
1230
2006
|
await this.requireVisibleItem(postId, 'post');
|
|
@@ -1232,40 +2008,36 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1232
2008
|
if (!roster) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${postId}`);
|
|
1233
2009
|
return roster;
|
|
1234
2010
|
}
|
|
1235
|
-
|
|
1236
2011
|
private async getNoteRecord(id: string): Promise<NoteRecord> {
|
|
1237
2012
|
await this.requireVisibleItem(id, 'note');
|
|
1238
2013
|
const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
1239
2014
|
if (!record) throw new SynomemError('NOTE_NOT_FOUND', `Unknown note: ${id}`);
|
|
1240
|
-
return record;
|
|
2015
|
+
return this.decorateRecord(record);
|
|
1241
2016
|
}
|
|
1242
2017
|
private async getNote(id: string): Promise<NoteRecord> {
|
|
1243
2018
|
this.checkAbort();
|
|
1244
2019
|
return await this.getNoteRecord(id);
|
|
1245
2020
|
}
|
|
1246
|
-
|
|
1247
2021
|
private async createNote(input: CreateNoteInput): Promise<CreateNoteResult> {
|
|
1248
2022
|
this.checkAbort();
|
|
1249
2023
|
await this.repository.assertEventCompatibility();
|
|
1250
2024
|
const parsed = this.validate(() => createNoteSchema.parse(input));
|
|
1251
2025
|
await this.assertTopicsExist(parsed.topicIds);
|
|
1252
|
-
const
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
if (!this.administrative && (this.actor.kind !== 'agent' || this.actor.id !== owner.id)) {
|
|
1259
|
-
throw new SynomemError('MUTATION_FORBIDDEN', 'Agents may create notes only for themselves.');
|
|
1260
|
-
}
|
|
2026
|
+
const owner = await this.resolveActor(parsed.owner ?? this.boundRef(), true);
|
|
2027
|
+
if (!this.canManage(owner))
|
|
2028
|
+
throw new SynomemError(
|
|
2029
|
+
'MUTATION_FORBIDDEN',
|
|
2030
|
+
'Only the owner or an authorized overseer may create private memory.',
|
|
2031
|
+
);
|
|
1261
2032
|
const outcome = await this.repository.transaction(async () => {
|
|
1262
2033
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'note.created');
|
|
1263
2034
|
if (prior?.type === 'note.created') return { id: prior.id, created: false };
|
|
1264
2035
|
const id = this.nextId();
|
|
1265
2036
|
const event: SynomemEvent = {
|
|
1266
2037
|
...this.eventBase(id, 1, id),
|
|
2038
|
+
...this.intervention({ kind: owner.kind, id: owner.id }),
|
|
1267
2039
|
type: 'note.created',
|
|
1268
|
-
|
|
2040
|
+
owner: { kind: owner.kind, id: owner.id },
|
|
1269
2041
|
ownerDisplayName: owner.displayName,
|
|
1270
2042
|
title: parsed.title,
|
|
1271
2043
|
body: parsed.body,
|
|
@@ -1279,26 +2051,20 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1279
2051
|
await this.repository.insertEvent(event);
|
|
1280
2052
|
return { id, created: true };
|
|
1281
2053
|
});
|
|
1282
|
-
if (outcome.created) await this.
|
|
2054
|
+
if (outcome.created) await this.syncActor(owner);
|
|
1283
2055
|
return {
|
|
1284
2056
|
record: await this.getNoteRecord(outcome.id),
|
|
1285
2057
|
created: outcome.created,
|
|
1286
2058
|
deduplicated: !outcome.created,
|
|
1287
2059
|
};
|
|
1288
2060
|
}
|
|
1289
|
-
|
|
1290
2061
|
private assertNoteOwner(record: NoteRecord): void {
|
|
1291
|
-
if (
|
|
1292
|
-
!this.administrative &&
|
|
1293
|
-
!(this.actor.kind === 'agent' && this.actor.id === record.event.ownerAgentId)
|
|
1294
|
-
) {
|
|
2062
|
+
if (!this.canManage(record.event.owner))
|
|
1295
2063
|
throw new SynomemError(
|
|
1296
2064
|
'MUTATION_FORBIDDEN',
|
|
1297
|
-
'Only the
|
|
2065
|
+
'Only the owner or an authorized overseer may change private memory.',
|
|
1298
2066
|
);
|
|
1299
|
-
}
|
|
1300
2067
|
}
|
|
1301
|
-
|
|
1302
2068
|
private async reviseNote(input: ReviseNoteInput): Promise<NoteRecord> {
|
|
1303
2069
|
const parsed = this.validate(() => reviseNoteSchema.parse(input));
|
|
1304
2070
|
await this.assertTopicsExist(parsed.topicIds);
|
|
@@ -1323,6 +2089,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1323
2089
|
'The note changed before this revision was stored.',
|
|
1324
2090
|
);
|
|
1325
2091
|
const event: SynomemEvent = {
|
|
2092
|
+
...this.intervention(record.event.owner),
|
|
1326
2093
|
...this.eventBase(record.event.id, parsed.expectedVersion + 1),
|
|
1327
2094
|
type: 'note.revised',
|
|
1328
2095
|
noteId: record.event.id,
|
|
@@ -1337,11 +2104,11 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1337
2104
|
};
|
|
1338
2105
|
await this.repository.insertEvent(event);
|
|
1339
2106
|
});
|
|
1340
|
-
await this.
|
|
2107
|
+
await this.syncActor(record.event.owner);
|
|
1341
2108
|
return await this.getNoteRecord(parsed.noteId);
|
|
1342
2109
|
}
|
|
1343
|
-
|
|
1344
2110
|
private async archiveNote(input: {
|
|
2111
|
+
expectedVersion: number;
|
|
1345
2112
|
noteId: string;
|
|
1346
2113
|
idempotencyKey?: string;
|
|
1347
2114
|
}): Promise<NoteRecord> {
|
|
@@ -1352,6 +2119,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1352
2119
|
const prior = await this.priorMutation(input.idempotencyKey, 'note.archived');
|
|
1353
2120
|
if (prior) return;
|
|
1354
2121
|
const event: SynomemEvent = {
|
|
2122
|
+
...this.intervention(record.event.owner),
|
|
1355
2123
|
...this.eventBase(
|
|
1356
2124
|
record.event.id,
|
|
1357
2125
|
await this.repository.nextAggregateVersion(record.event.id),
|
|
@@ -1362,51 +2130,40 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1362
2130
|
};
|
|
1363
2131
|
await this.repository.insertEvent(event);
|
|
1364
2132
|
});
|
|
1365
|
-
await this.
|
|
2133
|
+
await this.syncActor(record.event.owner);
|
|
1366
2134
|
return await this.getNoteRecord(input.noteId);
|
|
1367
2135
|
}
|
|
1368
|
-
|
|
1369
2136
|
private async getTaskRecord(id: string): Promise<TaskRecord> {
|
|
1370
2137
|
await this.requireVisibleItem(id, 'task');
|
|
1371
2138
|
const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
1372
2139
|
if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
|
|
1373
|
-
return record;
|
|
2140
|
+
return this.decorateRecord(record);
|
|
1374
2141
|
}
|
|
1375
2142
|
private async getTask(id: string): Promise<TaskRecord> {
|
|
1376
2143
|
this.checkAbort();
|
|
1377
2144
|
return await this.getTaskRecord(id);
|
|
1378
2145
|
}
|
|
1379
|
-
|
|
1380
2146
|
private async createTask(input: CreateTaskInput): Promise<CreateTaskResult> {
|
|
1381
2147
|
this.checkAbort();
|
|
1382
2148
|
await this.repository.assertEventCompatibility();
|
|
1383
2149
|
const parsed = this.validate(() => createTaskSchema.parse(input));
|
|
1384
2150
|
await this.assertTopicsExist(parsed.topicIds);
|
|
1385
|
-
const
|
|
1386
|
-
parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
|
|
1387
|
-
if (!assigneeId)
|
|
1388
|
-
throw new SynomemError(
|
|
1389
|
-
'INVALID_INPUT',
|
|
1390
|
-
'A human or system actor must specify assigneeAgentId.',
|
|
1391
|
-
);
|
|
1392
|
-
const assignee = await this.repository.getAgent(assigneeId);
|
|
1393
|
-
if (!assignee)
|
|
1394
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
|
|
2151
|
+
const assignee = await this.resolveActor(parsed.assignee ?? this.boundRef(), true);
|
|
1395
2152
|
if (
|
|
1396
2153
|
this.actor.kind === 'agent' &&
|
|
1397
|
-
this.actor
|
|
2154
|
+
!sameActor(this.actor, assignee) &&
|
|
1398
2155
|
!this.repository.config.allowCrossAgentTasks
|
|
1399
2156
|
)
|
|
1400
|
-
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-
|
|
2157
|
+
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-actor task assignment is disabled.');
|
|
1401
2158
|
const outcome = await this.repository.transaction(async () => {
|
|
1402
2159
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
|
|
1403
2160
|
if (prior?.type === 'task.created') return { id: prior.id, created: false };
|
|
1404
2161
|
const id = this.nextId();
|
|
1405
|
-
const requiresAcceptance = this.actor
|
|
2162
|
+
const requiresAcceptance = !sameActor(this.actor, assignee);
|
|
1406
2163
|
const event: SynomemEvent = {
|
|
1407
2164
|
...this.eventBase(id, 1, id),
|
|
1408
2165
|
type: 'task.created',
|
|
1409
|
-
|
|
2166
|
+
assignee: { kind: assignee.kind, id: assignee.id },
|
|
1410
2167
|
assigneeDisplayName: assignee.displayName,
|
|
1411
2168
|
title: parsed.title,
|
|
1412
2169
|
...(parsed.description !== undefined ? { description: parsed.description } : {}),
|
|
@@ -1423,38 +2180,27 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1423
2180
|
await this.repository.insertEvent(event);
|
|
1424
2181
|
return { id, created: true };
|
|
1425
2182
|
});
|
|
1426
|
-
if (outcome.created) await this.
|
|
2183
|
+
if (outcome.created) await this.syncActor(assignee);
|
|
1427
2184
|
return {
|
|
1428
2185
|
record: await this.getTaskRecord(outcome.id),
|
|
1429
2186
|
created: outcome.created,
|
|
1430
2187
|
deduplicated: !outcome.created,
|
|
1431
2188
|
};
|
|
1432
2189
|
}
|
|
1433
|
-
|
|
1434
2190
|
private assertTaskParticipant(record: TaskRecord): void {
|
|
1435
|
-
|
|
1436
|
-
record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
|
|
1437
|
-
const isAssignee =
|
|
1438
|
-
this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId;
|
|
1439
|
-
if (!this.administrative && !isCreator && !isAssignee)
|
|
2191
|
+
if (!this.canManage(record.event.assignee) && !this.canManage(record.event.actor as ActorRef))
|
|
1440
2192
|
throw new SynomemError(
|
|
1441
2193
|
'MUTATION_FORBIDDEN',
|
|
1442
|
-
'Only
|
|
2194
|
+
'Only task participants or authorized overseers may change it.',
|
|
1443
2195
|
);
|
|
1444
2196
|
}
|
|
1445
|
-
|
|
1446
2197
|
private assertTaskAssignee(record: TaskRecord): void {
|
|
1447
|
-
if (
|
|
1448
|
-
!this.administrative &&
|
|
1449
|
-
!(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)
|
|
1450
|
-
) {
|
|
2198
|
+
if (!this.canManage(record.event.assignee))
|
|
1451
2199
|
throw new SynomemError(
|
|
1452
2200
|
'MUTATION_FORBIDDEN',
|
|
1453
|
-
'Only the
|
|
2201
|
+
'Only the assignee or an authorized overseer may decide this task.',
|
|
1454
2202
|
);
|
|
1455
|
-
}
|
|
1456
2203
|
}
|
|
1457
|
-
|
|
1458
2204
|
private async updateTask(input: UpdateTaskInput): Promise<TaskRecord> {
|
|
1459
2205
|
const parsed = this.validate(() => updateTaskSchema.parse(input));
|
|
1460
2206
|
await this.assertTopicsExist(parsed.topicIds);
|
|
@@ -1480,6 +2226,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1480
2226
|
);
|
|
1481
2227
|
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
1482
2228
|
const event: SynomemEvent = {
|
|
2229
|
+
...this.intervention(record.event.assignee),
|
|
1483
2230
|
...this.eventBase(record.event.id, parsed.expectedVersion + 1),
|
|
1484
2231
|
type: 'task.updated',
|
|
1485
2232
|
taskId: record.event.id,
|
|
@@ -1500,12 +2247,57 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1500
2247
|
};
|
|
1501
2248
|
await this.repository.insertEvent(event);
|
|
1502
2249
|
});
|
|
1503
|
-
await this.
|
|
2250
|
+
await this.syncActor(record.event.assignee);
|
|
1504
2251
|
return await this.getTaskRecord(parsed.taskId);
|
|
1505
2252
|
}
|
|
1506
|
-
|
|
2253
|
+
private async overrideTaskDecision(input: OverrideTaskDecisionInput): Promise<TaskRecord> {
|
|
2254
|
+
this.checkAbort();
|
|
2255
|
+
const parsed = this.validate(() => overrideTaskDecisionSchema.parse(input));
|
|
2256
|
+
await this.repository.transaction(async () => {
|
|
2257
|
+
const record = await this.getTaskRecord(parsed.taskId);
|
|
2258
|
+
if (
|
|
2259
|
+
this.actor.kind !== 'human' ||
|
|
2260
|
+
!(
|
|
2261
|
+
this.administrative ||
|
|
2262
|
+
(record.event.assignee.kind === 'agent' &&
|
|
2263
|
+
overseesAgent(this.actor, this.authority, record.event.assignee.id)) ||
|
|
2264
|
+
(record.event.actor.kind === 'agent' &&
|
|
2265
|
+
overseesAgent(this.actor, this.authority, record.event.actor.id))
|
|
2266
|
+
)
|
|
2267
|
+
)
|
|
2268
|
+
throw new SynomemError(
|
|
2269
|
+
'MUTATION_FORBIDDEN',
|
|
2270
|
+
'Only an authorized human overseer may override a decision.',
|
|
2271
|
+
);
|
|
2272
|
+
if (record.current.version !== parsed.expectedVersion)
|
|
2273
|
+
throw new SynomemError(
|
|
2274
|
+
'REVISION_CONFLICT',
|
|
2275
|
+
'The task changed. Reload it before overriding.',
|
|
2276
|
+
);
|
|
2277
|
+
if (record.status !== 'open' && record.status !== 'rejected')
|
|
2278
|
+
throw new SynomemError(
|
|
2279
|
+
'INVALID_INPUT',
|
|
2280
|
+
'Only accepted/open or rejected decisions can be overridden.',
|
|
2281
|
+
);
|
|
2282
|
+
if (record.status === parsed.nextStatus)
|
|
2283
|
+
throw new SynomemError('INVALID_INPUT', 'The requested decision is already in effect.');
|
|
2284
|
+
const event: SynomemEvent = {
|
|
2285
|
+
...this.eventBase(parsed.taskId, parsed.expectedVersion + 1),
|
|
2286
|
+
...this.intervention(record.event.assignee, parsed.reason),
|
|
2287
|
+
type: 'task.decision_overridden',
|
|
2288
|
+
taskId: parsed.taskId,
|
|
2289
|
+
previousStatus: record.status,
|
|
2290
|
+
nextStatus: parsed.nextStatus,
|
|
2291
|
+
reason: parsed.reason,
|
|
2292
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
2293
|
+
};
|
|
2294
|
+
await this.repository.insertEvent(event);
|
|
2295
|
+
});
|
|
2296
|
+
return this.getTaskRecord(parsed.taskId);
|
|
2297
|
+
}
|
|
1507
2298
|
private async taskTransition(
|
|
1508
2299
|
input: {
|
|
2300
|
+
expectedVersion: number;
|
|
1509
2301
|
taskId: string;
|
|
1510
2302
|
idempotencyKey?: string;
|
|
1511
2303
|
note?: string;
|
|
@@ -1549,6 +2341,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1549
2341
|
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
1550
2342
|
if (prior) return;
|
|
1551
2343
|
const base = {
|
|
2344
|
+
...this.intervention(record.event.assignee),
|
|
1552
2345
|
...this.eventBase(
|
|
1553
2346
|
record.event.id,
|
|
1554
2347
|
await this.repository.nextAggregateVersion(record.event.id),
|
|
@@ -1572,25 +2365,44 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1572
2365
|
: { ...base, type };
|
|
1573
2366
|
await this.repository.insertEvent(event);
|
|
1574
2367
|
});
|
|
1575
|
-
await this.
|
|
2368
|
+
await this.syncActor(record.event.assignee);
|
|
1576
2369
|
return await this.getTaskRecord(input.taskId);
|
|
1577
2370
|
}
|
|
1578
|
-
private completeTask(input: {
|
|
2371
|
+
private completeTask(input: {
|
|
2372
|
+
expectedVersion: number;
|
|
2373
|
+
taskId: string;
|
|
2374
|
+
note?: string;
|
|
2375
|
+
idempotencyKey?: string;
|
|
2376
|
+
}) {
|
|
1579
2377
|
return this.taskTransition(input, 'task.completed');
|
|
1580
2378
|
}
|
|
1581
|
-
private acceptTask(input: {
|
|
2379
|
+
private acceptTask(input: {
|
|
2380
|
+
expectedVersion: number;
|
|
2381
|
+
taskId: string;
|
|
2382
|
+
response?: string;
|
|
2383
|
+
idempotencyKey?: string;
|
|
2384
|
+
}) {
|
|
1582
2385
|
return this.taskTransition(input, 'task.accepted');
|
|
1583
2386
|
}
|
|
1584
|
-
private rejectTask(input: {
|
|
2387
|
+
private rejectTask(input: {
|
|
2388
|
+
expectedVersion: number;
|
|
2389
|
+
taskId: string;
|
|
2390
|
+
response: string;
|
|
2391
|
+
idempotencyKey?: string;
|
|
2392
|
+
}) {
|
|
1585
2393
|
return this.taskTransition(input, 'task.rejected');
|
|
1586
2394
|
}
|
|
1587
|
-
private reopenTask(input: { taskId: string; idempotencyKey?: string }) {
|
|
2395
|
+
private reopenTask(input: { expectedVersion: number; taskId: string; idempotencyKey?: string }) {
|
|
1588
2396
|
return this.taskTransition(input, 'task.reopened');
|
|
1589
2397
|
}
|
|
1590
|
-
private cancelTask(input: {
|
|
2398
|
+
private cancelTask(input: {
|
|
2399
|
+
expectedVersion: number;
|
|
2400
|
+
taskId: string;
|
|
2401
|
+
reason?: string;
|
|
2402
|
+
idempotencyKey?: string;
|
|
2403
|
+
}) {
|
|
1591
2404
|
return this.taskTransition(input, 'task.canceled');
|
|
1592
2405
|
}
|
|
1593
|
-
|
|
1594
2406
|
/* ----------------------------------------------------------------- todos *
|
|
1595
2407
|
* A Todo is a private reminder an agent creates for itself. There is no
|
|
1596
2408
|
* assignee, no acceptance, and no visibility choice: it belongs to its author
|
|
@@ -1598,19 +2410,26 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1598
2410
|
* rather than relying on a visibility filter, so a Todo cannot be reached by
|
|
1599
2411
|
* guessing its id.
|
|
1600
2412
|
*/
|
|
1601
|
-
|
|
1602
2413
|
private async createTodo(input: CreateTodoInput): Promise<CreateTodoResult> {
|
|
1603
2414
|
this.checkAbort();
|
|
1604
2415
|
await this.repository.assertEventCompatibility();
|
|
1605
2416
|
const parsed = this.validate(() => createTodoSchema.parse(input));
|
|
1606
2417
|
await this.assertTopicsExist(parsed.topicIds);
|
|
2418
|
+
const owner = await this.resolveActor(parsed.owner ?? this.boundRef(), true);
|
|
2419
|
+
if (!this.canManage(owner))
|
|
2420
|
+
throw new SynomemError(
|
|
2421
|
+
'MUTATION_FORBIDDEN',
|
|
2422
|
+
'Only the owner or an authorized overseer may create private memory.',
|
|
2423
|
+
);
|
|
1607
2424
|
const outcome = await this.repository.transaction(async () => {
|
|
1608
2425
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
|
|
1609
2426
|
if (prior?.type === 'todo.created') return { id: prior.id, created: false };
|
|
1610
2427
|
const id = this.nextId();
|
|
1611
2428
|
const event: SynomemEvent = {
|
|
1612
2429
|
...this.eventBase(id, 1, id),
|
|
2430
|
+
...this.intervention({ kind: owner.kind, id: owner.id }),
|
|
1613
2431
|
type: 'todo.created',
|
|
2432
|
+
owner: { kind: owner.kind, id: owner.id },
|
|
1614
2433
|
title: parsed.title,
|
|
1615
2434
|
...(parsed.details !== undefined ? { details: parsed.details } : {}),
|
|
1616
2435
|
priority: parsed.priority ?? 3,
|
|
@@ -1630,14 +2449,12 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1630
2449
|
deduplicated: !outcome.created,
|
|
1631
2450
|
};
|
|
1632
2451
|
}
|
|
1633
|
-
|
|
1634
2452
|
private async getTodoRecord(id: string): Promise<TodoRecord> {
|
|
1635
2453
|
const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
1636
2454
|
if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
|
|
1637
2455
|
this.assertTodoOwner(record);
|
|
1638
|
-
return record;
|
|
2456
|
+
return this.decorateRecord(record);
|
|
1639
2457
|
}
|
|
1640
|
-
|
|
1641
2458
|
/**
|
|
1642
2459
|
* Todos are owner-only for every ordinary actor — the same rule notes
|
|
1643
2460
|
* already follow (`assertNoteOwner`): an organization owner or admin is the
|
|
@@ -1650,20 +2467,16 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1650
2467
|
* on top of a permission that had already been granted.
|
|
1651
2468
|
*/
|
|
1652
2469
|
private assertTodoOwner(record: TodoRecord): void {
|
|
1653
|
-
|
|
1654
|
-
if (!this.administrative && (owner.kind !== this.actor.kind || owner.id !== this.actor.id)) {
|
|
2470
|
+
if (!this.canManage(record.event.owner))
|
|
1655
2471
|
throw new SynomemError(
|
|
1656
2472
|
'MUTATION_FORBIDDEN',
|
|
1657
|
-
'
|
|
2473
|
+
'Only the owner or an authorized overseer may change private memory.',
|
|
1658
2474
|
);
|
|
1659
|
-
}
|
|
1660
2475
|
}
|
|
1661
|
-
|
|
1662
2476
|
private async getTodo(id: string): Promise<TodoRecord> {
|
|
1663
2477
|
this.checkAbort();
|
|
1664
2478
|
return await this.getTodoRecord(id);
|
|
1665
2479
|
}
|
|
1666
|
-
|
|
1667
2480
|
private async updateTodo(input: UpdateTodoInput): Promise<TodoRecord> {
|
|
1668
2481
|
this.checkAbort();
|
|
1669
2482
|
const parsed = this.validate(() => updateTodoSchema.parse(input));
|
|
@@ -1680,6 +2493,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1680
2493
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
|
|
1681
2494
|
if (prior) return;
|
|
1682
2495
|
const event: SynomemEvent = {
|
|
2496
|
+
...this.intervention(record.event.owner),
|
|
1683
2497
|
...this.eventBase(
|
|
1684
2498
|
record.event.id,
|
|
1685
2499
|
await this.repository.nextAggregateVersion(record.event.id),
|
|
@@ -1700,12 +2514,18 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1700
2514
|
});
|
|
1701
2515
|
return await this.getTodoRecord(parsed.todoId);
|
|
1702
2516
|
}
|
|
1703
|
-
|
|
1704
2517
|
private async todoTransition(
|
|
1705
|
-
input: {
|
|
2518
|
+
input: {
|
|
2519
|
+
expectedVersion: number;
|
|
2520
|
+
todoId: string;
|
|
2521
|
+
idempotencyKey?: string;
|
|
2522
|
+
note?: string;
|
|
2523
|
+
reason?: string;
|
|
2524
|
+
},
|
|
1706
2525
|
type: 'todo.completed' | 'todo.reopened' | 'todo.canceled' | 'todo.archived',
|
|
1707
2526
|
): Promise<TodoRecord> {
|
|
1708
2527
|
const record = await this.getTodoRecord(input.todoId);
|
|
2528
|
+
this.assertTodoOwner(record);
|
|
1709
2529
|
if (type === 'todo.completed' && record.status !== 'open') {
|
|
1710
2530
|
if (record.status === 'completed') return record;
|
|
1711
2531
|
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
|
|
@@ -1716,7 +2536,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1716
2536
|
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
|
|
1717
2537
|
}
|
|
1718
2538
|
if (type === 'todo.archived' && record.status === 'archived') return record;
|
|
1719
|
-
|
|
1720
2539
|
await this.repository.transaction(async () => {
|
|
1721
2540
|
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
1722
2541
|
if (prior) return;
|
|
@@ -1738,7 +2557,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1738
2557
|
});
|
|
1739
2558
|
return await this.getTodoRecord(input.todoId);
|
|
1740
2559
|
}
|
|
1741
|
-
|
|
1742
2560
|
/**
|
|
1743
2561
|
* Turns an agent name in a filter into the canonical ID the records hold.
|
|
1744
2562
|
*
|
|
@@ -1755,16 +2573,15 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1755
2573
|
const resolved = await this.repository.resolveAgent(name);
|
|
1756
2574
|
return resolved.match?.id ?? name;
|
|
1757
2575
|
}
|
|
1758
|
-
|
|
1759
2576
|
private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
|
|
1760
2577
|
this.checkAbort();
|
|
1761
2578
|
const parsed = this.validate(() => itemListInputSchema.parse(input));
|
|
1762
2579
|
const resolved = {
|
|
1763
2580
|
...parsed,
|
|
1764
|
-
...(parsed.
|
|
1765
|
-
|
|
2581
|
+
...(parsed.participant ? { participant: await this.resolveActor(parsed.participant) } : {}),
|
|
2582
|
+
...(parsed.actorId && parsed.actorKind === 'agent'
|
|
2583
|
+
? { actorId: await this.canonicalAgentId(parsed.actorId) }
|
|
1766
2584
|
: {}),
|
|
1767
|
-
...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
|
|
1768
2585
|
};
|
|
1769
2586
|
return await this.repository.listItemSummaries(resolved, this.actor);
|
|
1770
2587
|
}
|
|
@@ -1800,7 +2617,6 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1800
2617
|
if (summary.kind === 'todo') return this.getTodo(id);
|
|
1801
2618
|
return this.getTask(id);
|
|
1802
2619
|
}
|
|
1803
|
-
|
|
1804
2620
|
async stats(input: KudosListInput = {}): Promise<KudosStats> {
|
|
1805
2621
|
this.checkAbort();
|
|
1806
2622
|
const parsed = this.validate(() => listInputSchema.parse(input));
|
|
@@ -1808,11 +2624,11 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1808
2624
|
void _cursor;
|
|
1809
2625
|
void _limit;
|
|
1810
2626
|
void _offset;
|
|
1811
|
-
const recipient = filters.
|
|
1812
|
-
? await this.
|
|
2627
|
+
const recipient = filters.recipient?.id
|
|
2628
|
+
? await this.resolveActor(filters.recipient)
|
|
1813
2629
|
: undefined;
|
|
1814
|
-
if (filters.
|
|
1815
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.
|
|
2630
|
+
if (filters.recipient?.id && !recipient) {
|
|
2631
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipient?.id}`);
|
|
1816
2632
|
}
|
|
1817
2633
|
const records: KudosSummary[] = [];
|
|
1818
2634
|
let cursor: string | undefined;
|
|
@@ -1822,7 +2638,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1822
2638
|
const page = await this.repository.listKudosSummaries(
|
|
1823
2639
|
{
|
|
1824
2640
|
...filters,
|
|
1825
|
-
...(recipient ? {
|
|
2641
|
+
...(recipient ? { recipient: { kind: recipient.kind, id: recipient.id } } : {}),
|
|
1826
2642
|
limit: 50,
|
|
1827
2643
|
offset: 0,
|
|
1828
2644
|
...(cursor ? { cursor } : {}),
|
|
@@ -1848,7 +2664,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1848
2664
|
byTag: {},
|
|
1849
2665
|
};
|
|
1850
2666
|
for (const record of includedRecords) {
|
|
1851
|
-
stats.byAgent[record.
|
|
2667
|
+
stats.byAgent[record.recipient?.id] = (stats.byAgent[record.recipient?.id] ?? 0) + 1;
|
|
1852
2668
|
const actor = `${record.actor.kind}:${record.actor.id}`;
|
|
1853
2669
|
stats.byActor[actor] = (stats.byActor[actor] ?? 0) + 1;
|
|
1854
2670
|
for (const tag of record.tags) stats.byTag[tag] = (stats.byTag[tag] ?? 0) + 1;
|
|
@@ -1856,26 +2672,25 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1856
2672
|
return stats;
|
|
1857
2673
|
}
|
|
1858
2674
|
}
|
|
1859
|
-
|
|
1860
2675
|
/**
|
|
1861
2676
|
* The schema version this package writes and expects. Named rather than
|
|
1862
2677
|
* repeated as a literal because it is asserted in three places, and a doctor
|
|
1863
2678
|
* check that silently lags the migration runner reports a healthy database as
|
|
1864
2679
|
* broken.
|
|
1865
2680
|
*/
|
|
1866
|
-
const CURRENT_SCHEMA_VERSION =
|
|
1867
|
-
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8];
|
|
1868
|
-
|
|
2681
|
+
const CURRENT_SCHEMA_VERSION = 9;
|
|
2682
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
1869
2683
|
export class SynomemClient extends SynomemCore implements SynomemService {
|
|
1870
2684
|
readonly home: string;
|
|
1871
2685
|
readonly storage: SynomemStorage;
|
|
1872
2686
|
readonly projections: ProjectionManager;
|
|
1873
|
-
|
|
1874
2687
|
constructor(options: SynomemClientOptions = {}) {
|
|
1875
2688
|
const home = resolveHome(options.home);
|
|
1876
2689
|
const storage = new SynomemStorage({
|
|
1877
2690
|
home,
|
|
2691
|
+
...(options.actor ? { actor: options.actor } : {}),
|
|
1878
2692
|
readOnly: options.readOnly ?? false,
|
|
2693
|
+
...(options.authority ? { authority: options.authority } : {}),
|
|
1879
2694
|
...(options.config ? { config: options.config } : {}),
|
|
1880
2695
|
});
|
|
1881
2696
|
const projections = new ProjectionManager(storage);
|
|
@@ -1883,16 +2698,16 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1883
2698
|
repository: storage,
|
|
1884
2699
|
projectionWriter: projections,
|
|
1885
2700
|
...(options.actor ? { actor: options.actor } : {}),
|
|
1886
|
-
...(options.
|
|
2701
|
+
...(options.authority ? { authority: options.authority } : {}),
|
|
1887
2702
|
...(options.clock ? { clock: options.clock } : {}),
|
|
1888
2703
|
...(options.idGenerator ? { idGenerator: options.idGenerator } : {}),
|
|
1889
2704
|
...(options.signal ? { signal: options.signal } : {}),
|
|
2705
|
+
canWrite: !options.readOnly,
|
|
1890
2706
|
});
|
|
1891
2707
|
this.home = home;
|
|
1892
2708
|
this.storage = storage;
|
|
1893
2709
|
this.projections = projections;
|
|
1894
2710
|
}
|
|
1895
|
-
|
|
1896
2711
|
/*
|
|
1897
2712
|
* Answers "would a rebuild change anything, and when did one last run?"
|
|
1898
2713
|
*
|
|
@@ -1927,7 +2742,6 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1927
2742
|
unexpected: unexpected.slice(0, limit),
|
|
1928
2743
|
};
|
|
1929
2744
|
}
|
|
1930
|
-
|
|
1931
2745
|
async doctor(): Promise<DoctorResult> {
|
|
1932
2746
|
this.checkAbort();
|
|
1933
2747
|
const diagnostics: Diagnostic[] = [];
|
|
@@ -2055,10 +2869,33 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
2055
2869
|
}
|
|
2056
2870
|
return { healthy: !diagnostics.some((item) => item.level === 'error'), diagnostics };
|
|
2057
2871
|
}
|
|
2058
|
-
|
|
2059
2872
|
async export(format: 'json' | 'jsonl' | 'markdown'): Promise<string> {
|
|
2060
2873
|
this.checkAbort();
|
|
2061
|
-
const rows =
|
|
2874
|
+
const rows = [] as ReturnType<SynomemStorage['rawEventRows']>;
|
|
2875
|
+
const access = new Map<string, boolean>();
|
|
2876
|
+
for (const row of this.storage.rawEventRows()) {
|
|
2877
|
+
let event: SynomemEvent;
|
|
2878
|
+
try {
|
|
2879
|
+
event = eventSchema.parse(JSON.parse(row.payload));
|
|
2880
|
+
} catch {
|
|
2881
|
+
if (this.administrative) rows.push(row);
|
|
2882
|
+
continue;
|
|
2883
|
+
}
|
|
2884
|
+
const rootId = 'rootId' in event ? event.rootId : event.aggregateId;
|
|
2885
|
+
if (event.type.startsWith('agent.')) {
|
|
2886
|
+
if (this.administrative) rows.push(row);
|
|
2887
|
+
continue;
|
|
2888
|
+
}
|
|
2889
|
+
if (!access.has(rootId))
|
|
2890
|
+
access.set(rootId, this.storage.canActorReadItem(rootId, this.actor as ActorRef));
|
|
2891
|
+
if (!access.get(rootId)) continue;
|
|
2892
|
+
if (
|
|
2893
|
+
event.type === 'reply.created' &&
|
|
2894
|
+
(await this.storage.participation.getReply(event.id))?.deleted
|
|
2895
|
+
)
|
|
2896
|
+
continue;
|
|
2897
|
+
rows.push(row);
|
|
2898
|
+
}
|
|
2062
2899
|
if (format === 'jsonl') return `${rows.map((row) => row.payload).join('\n')}\n`;
|
|
2063
2900
|
const exported = rows.map((row) => {
|
|
2064
2901
|
try {
|
|
@@ -2069,7 +2906,8 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
2069
2906
|
});
|
|
2070
2907
|
if (format === 'json') return `${JSON.stringify(exported, null, 2)}\n`;
|
|
2071
2908
|
const scan = this.storage.scanEvents();
|
|
2072
|
-
const
|
|
2909
|
+
const allowedIds = new Set(rows.map((row) => row.id));
|
|
2910
|
+
const events = scan.events.filter((event) => allowedIds.has(event.id));
|
|
2073
2911
|
const records = recordsFromEvents(events);
|
|
2074
2912
|
const memos = memoRecordsFromEvents(events);
|
|
2075
2913
|
const notes = noteRecordsFromEvents(events);
|
|
@@ -2097,21 +2935,28 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
2097
2935
|
];
|
|
2098
2936
|
return `${warning}${sections.join('\n\n')}\n`;
|
|
2099
2937
|
}
|
|
2100
|
-
|
|
2101
2938
|
async backup(destination: string): Promise<string> {
|
|
2102
2939
|
this.checkAbort();
|
|
2103
2940
|
return this.storage.backup(resolve(destination));
|
|
2104
2941
|
}
|
|
2105
|
-
|
|
2106
2942
|
async rebuild(): Promise<ProjectionRebuildResult> {
|
|
2107
2943
|
this.checkAbort();
|
|
2108
2944
|
return this.projections.rebuild();
|
|
2109
2945
|
}
|
|
2110
|
-
|
|
2111
2946
|
async capabilities(): Promise<SynomemServiceCapabilities> {
|
|
2112
2947
|
this.checkAbort();
|
|
2113
2948
|
return {
|
|
2114
2949
|
backend: 'local',
|
|
2950
|
+
participation: {
|
|
2951
|
+
version: 2,
|
|
2952
|
+
replies: true,
|
|
2953
|
+
reactions: true,
|
|
2954
|
+
personalInbox: true,
|
|
2955
|
+
search: false,
|
|
2956
|
+
canWrite: !this.storage.readOnly,
|
|
2957
|
+
administrator: this.administrative,
|
|
2958
|
+
managedAgentIds: [...this.authority.operatedAgentIds],
|
|
2959
|
+
},
|
|
2115
2960
|
binding: { workspaceId: this.storage.config.workspaceId, actor: this.actor },
|
|
2116
2961
|
administration: {
|
|
2117
2962
|
agentCreationViaMcp: this.storage.config.allowAgentCreationViaMcp,
|
|
@@ -2121,14 +2966,33 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
2121
2966
|
projections: { ...this.storage.config.projection },
|
|
2122
2967
|
};
|
|
2123
2968
|
}
|
|
2124
|
-
|
|
2125
2969
|
async info(): Promise<SynomemServiceInfo> {
|
|
2126
2970
|
this.checkAbort();
|
|
2127
2971
|
return { backend: 'local', home: this.home, databasePath: this.storage.databasePath };
|
|
2128
2972
|
}
|
|
2129
|
-
|
|
2130
2973
|
async getCanonicalEvent(id: string): Promise<SynomemEvent | undefined> {
|
|
2131
2974
|
this.checkAbort();
|
|
2132
|
-
|
|
2975
|
+
const event = this.storage.getEvent(id);
|
|
2976
|
+
if (!event) return undefined;
|
|
2977
|
+
if (event.type.startsWith('agent.')) return this.administrative ? event : undefined;
|
|
2978
|
+
const rootId = 'rootId' in event ? event.rootId : event.aggregateId;
|
|
2979
|
+
if (!this.storage.canActorReadItem(rootId, this.actor as ActorRef))
|
|
2980
|
+
throw new SynomemError('POLICY_FORBIDDEN', 'This event is not visible.');
|
|
2981
|
+
if (
|
|
2982
|
+
event.type === 'reply.created' &&
|
|
2983
|
+
(await this.storage.participation.getReply(event.id))?.deleted
|
|
2984
|
+
)
|
|
2985
|
+
return undefined;
|
|
2986
|
+
return event;
|
|
2133
2987
|
}
|
|
2134
2988
|
}
|
|
2989
|
+
export function createLocalOwnerClient(
|
|
2990
|
+
options: Omit<SynomemClientOptions, 'authority'> = {},
|
|
2991
|
+
): SynomemClient {
|
|
2992
|
+
if (options.actor?.kind !== 'human')
|
|
2993
|
+
throw new SynomemError(
|
|
2994
|
+
'POLICY_FORBIDDEN',
|
|
2995
|
+
'Local owner administration requires an explicit human actor.',
|
|
2996
|
+
);
|
|
2997
|
+
return new SynomemClient({ ...options, authority: localOwnerAuthority() });
|
|
2998
|
+
}
|