synomem 0.1.1 → 0.3.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/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +37 -0
- package/README.md +26 -13
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +379 -51
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +131 -17
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +489 -76
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +8 -8
- package/dist/import.d.ts +356 -13
- package/dist/import.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +213 -31
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp-server.js +54 -8
- package/dist/mcp-server.js.map +1 -1
- package/dist/ports/repository.d.ts +21 -1
- package/dist/ports/repository.d.ts.map +1 -1
- package/dist/projections.d.ts +26 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +208 -44
- package/dist/projections.js.map +1 -1
- package/dist/remote.d.ts +82 -9
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +53 -2
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +476 -21
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +204 -26
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +82 -10
- package/dist/service.d.ts.map +1 -1
- package/dist/skill-install.d.ts +8 -2
- package/dist/skill-install.d.ts.map +1 -1
- package/dist/skill-install.js +6 -11
- package/dist/skill-install.js.map +1 -1
- package/dist/storage.d.ts +51 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +366 -36
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +310 -38
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +53 -20
- package/docs/examples.md +4 -4
- package/docs/mcp.md +12 -4
- package/docs/skill.md +10 -7
- package/docs/storage-format.md +4 -4
- package/openapi/synomem-v1.yaml +24 -24
- package/package.json +1 -1
- package/skills/synomem/SKILL.md +24 -8
- package/skills/synomem/agents/openai.yaml +1 -1
- package/skills/synomem/references/examples.md +1 -1
- package/src/cli.ts +610 -95
- package/src/client.ts +587 -79
- package/src/config.ts +8 -8
- package/src/index.ts +11 -1
- package/src/mcp/index.ts +265 -30
- package/src/mcp-server.ts +58 -8
- package/src/ports/repository.ts +21 -0
- package/src/projections.ts +209 -44
- package/src/remote.ts +166 -8
- package/src/schemas.ts +211 -26
- package/src/service.ts +85 -7
- package/src/skill-install.ts +14 -17
- package/src/storage.ts +472 -34
- package/src/types.ts +332 -39
package/src/client.ts
CHANGED
|
@@ -8,15 +8,21 @@ import {
|
|
|
8
8
|
escapeMarkdown,
|
|
9
9
|
memoRecordsFromEvents,
|
|
10
10
|
noteRecordsFromEvents,
|
|
11
|
+
postRecordsFromEvents,
|
|
11
12
|
ProjectionManager,
|
|
12
13
|
recordsFromEvents,
|
|
14
|
+
taskRecordsFromEvents,
|
|
13
15
|
todoRecordsFromEvents,
|
|
14
16
|
} from './projections.js';
|
|
15
17
|
import {
|
|
16
18
|
actorSchema,
|
|
17
|
-
|
|
19
|
+
agentLookupSchema,
|
|
20
|
+
bindRuntimeSchema,
|
|
18
21
|
createAgentSchema,
|
|
22
|
+
createPostSchema,
|
|
23
|
+
updatePostSchema,
|
|
19
24
|
createNoteSchema,
|
|
25
|
+
createTaskSchema,
|
|
20
26
|
createTodoSchema,
|
|
21
27
|
changesInputSchema,
|
|
22
28
|
giveKudosSchema,
|
|
@@ -24,6 +30,7 @@ import {
|
|
|
24
30
|
listInputSchema,
|
|
25
31
|
reviseNoteSchema,
|
|
26
32
|
sendMemoSchema,
|
|
33
|
+
updateTaskSchema,
|
|
27
34
|
updateTodoSchema,
|
|
28
35
|
updateAgentSchema,
|
|
29
36
|
} from './schemas.js';
|
|
@@ -39,8 +46,16 @@ import type { SynomemRepository } from './ports/repository.js';
|
|
|
39
46
|
import type { ProjectionWriter } from './ports/projections.js';
|
|
40
47
|
import type {
|
|
41
48
|
ActorIdentity,
|
|
49
|
+
AgentDirectoryEntry,
|
|
42
50
|
AgentProfile,
|
|
51
|
+
AgentResolution,
|
|
52
|
+
AgentRuntimeBinding,
|
|
53
|
+
BindRuntimeInput,
|
|
43
54
|
CreateAgentInput,
|
|
55
|
+
CreatePostInput,
|
|
56
|
+
PostRecord,
|
|
57
|
+
PostRoster,
|
|
58
|
+
UpdatePostInput,
|
|
44
59
|
Diagnostic,
|
|
45
60
|
DoctorResult,
|
|
46
61
|
GiveKudosInput,
|
|
@@ -52,9 +67,13 @@ import type {
|
|
|
52
67
|
CreateNoteResult,
|
|
53
68
|
ReviseNoteInput,
|
|
54
69
|
NoteRecord,
|
|
70
|
+
CreateTaskInput,
|
|
55
71
|
CreateTodoInput,
|
|
56
72
|
CreateTodoResult,
|
|
73
|
+
CreateTaskResult,
|
|
74
|
+
UpdateTaskInput,
|
|
57
75
|
UpdateTodoInput,
|
|
76
|
+
TaskRecord,
|
|
58
77
|
TodoRecord,
|
|
59
78
|
ItemListInput,
|
|
60
79
|
ItemRecord,
|
|
@@ -101,6 +120,11 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
101
120
|
update: (id: string, changes: UpdateAgentInput) => this.updateAgent(id, changes),
|
|
102
121
|
get: (idOrAlias: string) => this.getAgent(idOrAlias),
|
|
103
122
|
list: () => this.listAgents(),
|
|
123
|
+
resolve: (query: string) => this.resolveAgent(query),
|
|
124
|
+
directory: () => this.agentDirectory(),
|
|
125
|
+
bindings: (idOrAlias: string) => this.listRuntimeBindings(idOrAlias),
|
|
126
|
+
bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
|
|
127
|
+
unbindRuntime: (bindingId: string) => this.unbindRuntime(bindingId),
|
|
104
128
|
};
|
|
105
129
|
|
|
106
130
|
readonly kudos = {
|
|
@@ -122,6 +146,27 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
122
146
|
archive: (input: { memoId: string; idempotencyKey?: string }) => this.archiveMemo(input),
|
|
123
147
|
};
|
|
124
148
|
|
|
149
|
+
readonly posts = {
|
|
150
|
+
create: (input: CreatePostInput) => this.createPost(input),
|
|
151
|
+
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
152
|
+
this.listItems({ ...input, kinds: ['post'] }),
|
|
153
|
+
get: (id: string) => this.getPost(id),
|
|
154
|
+
update: (input: UpdatePostInput) => this.updatePost(input),
|
|
155
|
+
archive: (input: { postId: string; reason?: string; idempotencyKey?: string }) =>
|
|
156
|
+
this.archivePost(input),
|
|
157
|
+
/*
|
|
158
|
+
* Acknowledging is an explicit call and always speaks for the caller alone.
|
|
159
|
+
* There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
|
|
160
|
+
* is one actor saying "I have seen this", and reading a post must never
|
|
161
|
+
* append one, or the roster stops meaning anything.
|
|
162
|
+
*/
|
|
163
|
+
acknowledge: (input: { postId: string; note?: string; idempotencyKey?: string }) =>
|
|
164
|
+
this.acknowledgePost(input),
|
|
165
|
+
withdrawAcknowledgment: (input: { postId: string; reason?: string }) =>
|
|
166
|
+
this.withdrawPostAcknowledgment(input),
|
|
167
|
+
roster: (postId: string) => this.postRoster(postId),
|
|
168
|
+
};
|
|
169
|
+
|
|
125
170
|
readonly notes = {
|
|
126
171
|
create: (input: CreateNoteInput) => this.createNote(input),
|
|
127
172
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
@@ -131,20 +176,74 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
131
176
|
archive: (input: { noteId: string; idempotencyKey?: string }) => this.archiveNote(input),
|
|
132
177
|
};
|
|
133
178
|
|
|
179
|
+
readonly tasks = {
|
|
180
|
+
create: (input: CreateTaskInput) => this.createTask(input),
|
|
181
|
+
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
182
|
+
this.listItems({ ...input, kinds: ['task'] }),
|
|
183
|
+
get: (id: string) => this.getTask(id),
|
|
184
|
+
update: (input: UpdateTaskInput) => this.updateTask(input),
|
|
185
|
+
// A response is optional when accepting and required when rejecting: a
|
|
186
|
+
// refusal without a reason leaves the assigner unable to act on it.
|
|
187
|
+
accept: (input: { taskId: string; response?: string; idempotencyKey?: string }) =>
|
|
188
|
+
this.acceptTask(input),
|
|
189
|
+
reject: (input: { taskId: string; response: string; idempotencyKey?: string }) =>
|
|
190
|
+
this.rejectTask(input),
|
|
191
|
+
complete: (input: { taskId: string; note?: string; idempotencyKey?: string }) =>
|
|
192
|
+
this.completeTask(input),
|
|
193
|
+
reopen: (input: { taskId: string; idempotencyKey?: string }) => this.reopenTask(input),
|
|
194
|
+
cancel: (input: { taskId: string; reason?: string; idempotencyKey?: string }) =>
|
|
195
|
+
this.cancelTask(input),
|
|
196
|
+
};
|
|
197
|
+
|
|
134
198
|
readonly todos = {
|
|
135
199
|
create: (input: CreateTodoInput) => this.createTodo(input),
|
|
136
200
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
137
201
|
this.listItems({ ...input, kinds: ['todo'] }),
|
|
138
202
|
get: (id: string) => this.getTodo(id),
|
|
139
203
|
update: (input: UpdateTodoInput) => this.updateTodo(input),
|
|
140
|
-
accept: (input: { todoId: string; idempotencyKey?: string }) => this.acceptTodo(input),
|
|
141
|
-
reject: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
|
|
142
|
-
this.rejectTodo(input),
|
|
143
204
|
complete: (input: { todoId: string; note?: string; idempotencyKey?: string }) =>
|
|
144
|
-
this.
|
|
145
|
-
reopen: (input: { todoId: string; idempotencyKey?: string }) =>
|
|
205
|
+
this.todoTransition(input, 'todo.completed'),
|
|
206
|
+
reopen: (input: { todoId: string; idempotencyKey?: string }) =>
|
|
207
|
+
this.todoTransition(input, 'todo.reopened'),
|
|
146
208
|
cancel: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
|
|
147
|
-
this.
|
|
209
|
+
this.todoTransition(input, 'todo.canceled'),
|
|
210
|
+
archive: (input: { todoId: string; idempotencyKey?: string }) =>
|
|
211
|
+
this.todoTransition(input, 'todo.archived'),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Unanswered and overdue discovery.
|
|
216
|
+
*
|
|
217
|
+
* The plan asks for shared work to be observable without treating runtime
|
|
218
|
+
* metadata as a delivery guarantee. These are query states derived from
|
|
219
|
+
* durable events: they say nobody has answered yet, not that the agent was
|
|
220
|
+
* offline, missed a notification, or lacks a capability it claimed.
|
|
221
|
+
*/
|
|
222
|
+
readonly discovery = {
|
|
223
|
+
/**
|
|
224
|
+
* Tasks awaiting acceptance, unread memos, and unacknowledged kudos —
|
|
225
|
+
* optionally only those older than a given age or instant.
|
|
226
|
+
*/
|
|
227
|
+
unanswered: (
|
|
228
|
+
input: Omit<ItemListInput, 'awaitingResponse' | 'pending'> & { olderThanHours?: number } = {},
|
|
229
|
+
) => {
|
|
230
|
+
const { olderThanHours, awaitingSince, ...rest } = input;
|
|
231
|
+
const since =
|
|
232
|
+
awaitingSince ??
|
|
233
|
+
(olderThanHours !== undefined
|
|
234
|
+
? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
|
|
235
|
+
: undefined);
|
|
236
|
+
return this.listItems({
|
|
237
|
+
...rest,
|
|
238
|
+
awaitingResponse: true,
|
|
239
|
+
...(since ? { awaitingSince: since } : {}),
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
/** Open work whose deadline has passed. Defaults to "now". */
|
|
243
|
+
overdue: (input: Omit<ItemListInput, 'overdueAsOf'> & { asOf?: string } = {}) => {
|
|
244
|
+
const { asOf, ...rest } = input;
|
|
245
|
+
return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
|
|
246
|
+
},
|
|
148
247
|
};
|
|
149
248
|
|
|
150
249
|
readonly items = {
|
|
@@ -251,7 +350,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
251
350
|
private async updateAgent(idOrAlias: string, changes: UpdateAgentInput): Promise<AgentProfile> {
|
|
252
351
|
this.checkAbort();
|
|
253
352
|
await this.repository.assertEventCompatibility();
|
|
254
|
-
this.validate(() =>
|
|
353
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
255
354
|
const parsed = this.validate(() => updateAgentSchema.parse(changes));
|
|
256
355
|
const existing = await this.repository.getAgent(idOrAlias);
|
|
257
356
|
if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
@@ -287,7 +386,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
287
386
|
|
|
288
387
|
private async getAgent(idOrAlias: string): Promise<AgentProfile> {
|
|
289
388
|
this.checkAbort();
|
|
290
|
-
this.validate(() =>
|
|
389
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
291
390
|
const profile = await this.repository.getAgent(idOrAlias);
|
|
292
391
|
if (!profile) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
293
392
|
return profile;
|
|
@@ -298,6 +397,77 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
298
397
|
return await this.repository.listAgents();
|
|
299
398
|
}
|
|
300
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Resolves a name without ever choosing between equally valid answers.
|
|
402
|
+
*
|
|
403
|
+
* Callers that want a single agent should treat an empty `match` as a
|
|
404
|
+
* question for the user, not as "not found": `candidates` distinguishes the
|
|
405
|
+
* two cases.
|
|
406
|
+
*/
|
|
407
|
+
private async resolveAgent(query: string): Promise<AgentResolution> {
|
|
408
|
+
this.checkAbort();
|
|
409
|
+
const trimmed = query.trim();
|
|
410
|
+
if (!trimmed) throw new SynomemError('INVALID_INPUT', 'A lookup name is required.');
|
|
411
|
+
const resolved = await this.repository.resolveAgent(trimmed);
|
|
412
|
+
return {
|
|
413
|
+
query: trimmed,
|
|
414
|
+
...(resolved.match ? { match: resolved.match } : {}),
|
|
415
|
+
candidates: resolved.candidates,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private async agentDirectory(): Promise<AgentDirectoryEntry[]> {
|
|
420
|
+
this.checkAbort();
|
|
421
|
+
const profiles = await this.repository.listAgents();
|
|
422
|
+
const entries: AgentDirectoryEntry[] = [];
|
|
423
|
+
for (const profile of profiles) {
|
|
424
|
+
entries.push({
|
|
425
|
+
profile,
|
|
426
|
+
runtimeBindings: await this.repository.listRuntimeBindings(profile.id),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return entries;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private async listRuntimeBindings(idOrAlias: string): Promise<AgentRuntimeBinding[]> {
|
|
433
|
+
const profile = await this.getAgent(idOrAlias);
|
|
434
|
+
return await this.repository.listRuntimeBindings(profile.id);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Records where an agent runs. Re-binding the same runtime and profile
|
|
439
|
+
* updates the claim in place rather than accumulating duplicates, because a
|
|
440
|
+
* reinstall is the same agent in the same place, not a second one.
|
|
441
|
+
*/
|
|
442
|
+
private async bindRuntime(input: BindRuntimeInput): Promise<AgentRuntimeBinding> {
|
|
443
|
+
this.checkAbort();
|
|
444
|
+
const parsed = this.validate(() => bindRuntimeSchema.parse(input));
|
|
445
|
+
const profile = await this.getAgent(parsed.agentId);
|
|
446
|
+
await this.repository.bindRuntime({
|
|
447
|
+
id: this.idGenerator(),
|
|
448
|
+
agentId: profile.id,
|
|
449
|
+
...(parsed.installationId !== undefined ? { installationId: parsed.installationId } : {}),
|
|
450
|
+
runtime: parsed.runtime,
|
|
451
|
+
...(parsed.profile !== undefined ? { profile: parsed.profile } : {}),
|
|
452
|
+
...(parsed.capabilities !== undefined ? { capabilities: parsed.capabilities } : {}),
|
|
453
|
+
boundAt: this.now(),
|
|
454
|
+
});
|
|
455
|
+
const bindings = await this.repository.listRuntimeBindings(profile.id);
|
|
456
|
+
const binding = bindings.find(
|
|
457
|
+
(candidate) =>
|
|
458
|
+
candidate.runtime === parsed.runtime &&
|
|
459
|
+
(candidate.profile ?? '') === (parsed.profile ?? '') &&
|
|
460
|
+
(candidate.installationId ?? '') === (parsed.installationId ?? ''),
|
|
461
|
+
);
|
|
462
|
+
if (!binding) throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
|
|
463
|
+
return binding;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async unbindRuntime(bindingId: string): Promise<boolean> {
|
|
467
|
+
this.checkAbort();
|
|
468
|
+
return await this.repository.unbindRuntime(bindingId);
|
|
469
|
+
}
|
|
470
|
+
|
|
301
471
|
private async giveKudos(input: GiveKudosInput): Promise<GiveKudosResult> {
|
|
302
472
|
this.checkAbort();
|
|
303
473
|
await this.repository.assertEventCompatibility();
|
|
@@ -493,7 +663,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
493
663
|
? 'MEMO_NOT_FOUND'
|
|
494
664
|
: kind === 'note'
|
|
495
665
|
? 'NOTE_NOT_FOUND'
|
|
496
|
-
: kind === '
|
|
666
|
+
: kind === 'task'
|
|
497
667
|
? 'TODO_NOT_FOUND'
|
|
498
668
|
: 'KUDOS_NOT_FOUND';
|
|
499
669
|
throw new SynomemError(code, `Unknown ${kind}: ${id}`);
|
|
@@ -615,6 +785,174 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
615
785
|
return await this.getMemoRecord(input.memoId);
|
|
616
786
|
}
|
|
617
787
|
|
|
788
|
+
private async getPostRecord(id: string): Promise<PostRecord> {
|
|
789
|
+
await this.requireVisibleItem(id, 'post');
|
|
790
|
+
const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
791
|
+
if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
|
|
792
|
+
return record;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private async getPost(id: string): Promise<PostRecord> {
|
|
796
|
+
this.checkAbort();
|
|
797
|
+
return await this.getPostRecord(id);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private async createPost(input: CreatePostInput): Promise<{
|
|
801
|
+
record: PostRecord;
|
|
802
|
+
created: boolean;
|
|
803
|
+
deduplicated: boolean;
|
|
804
|
+
}> {
|
|
805
|
+
this.checkAbort();
|
|
806
|
+
await this.repository.assertEventCompatibility();
|
|
807
|
+
const parsed = this.validate(() => createPostSchema.parse(input));
|
|
808
|
+
|
|
809
|
+
// A reply inherits its parent's workspace by construction, and cannot name
|
|
810
|
+
// a different target — there is no target to name.
|
|
811
|
+
if (parsed.replyTo) await this.requireVisibleItem(parsed.replyTo, 'post');
|
|
812
|
+
|
|
813
|
+
const outcome = await this.repository.transaction(async () => {
|
|
814
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
|
|
815
|
+
if (prior?.type === 'post.created') return { id: prior.id, created: false };
|
|
816
|
+
const id = this.nextId();
|
|
817
|
+
const event: SynomemEvent = {
|
|
818
|
+
...this.eventBase(id, 1, id),
|
|
819
|
+
type: 'post.created',
|
|
820
|
+
title: parsed.title,
|
|
821
|
+
body: parsed.body,
|
|
822
|
+
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
823
|
+
...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
|
|
824
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
825
|
+
...(parsed.source ? { source: parsed.source } : {}),
|
|
826
|
+
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
827
|
+
};
|
|
828
|
+
await this.repository.insertEvent(event);
|
|
829
|
+
return { id, created: true };
|
|
830
|
+
});
|
|
831
|
+
return {
|
|
832
|
+
record: await this.getPostRecord(outcome.id),
|
|
833
|
+
created: outcome.created,
|
|
834
|
+
deduplicated: !outcome.created,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Only the author edits a post. Everyone else responds to it. */
|
|
839
|
+
private assertPostAuthor(record: PostRecord): void {
|
|
840
|
+
if (this.administrative) return;
|
|
841
|
+
if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
|
|
842
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private async updatePost(input: UpdatePostInput): Promise<PostRecord> {
|
|
847
|
+
this.checkAbort();
|
|
848
|
+
const parsed = this.validate(() => updatePostSchema.parse(input));
|
|
849
|
+
const record = await this.getPostRecord(parsed.postId);
|
|
850
|
+
this.assertPostAuthor(record);
|
|
851
|
+
if (record.status === 'archived') {
|
|
852
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'An archived post cannot be edited.');
|
|
853
|
+
}
|
|
854
|
+
if (record.version !== parsed.expectedVersion) {
|
|
855
|
+
throw new SynomemError(
|
|
856
|
+
'REVISION_CONFLICT',
|
|
857
|
+
`Post ${parsed.postId} is at version ${record.version}.`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
await this.repository.transaction(async () => {
|
|
861
|
+
const event: SynomemEvent = {
|
|
862
|
+
...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
|
|
863
|
+
type: 'post.edited',
|
|
864
|
+
postId: parsed.postId,
|
|
865
|
+
title: parsed.title ?? record.title,
|
|
866
|
+
body: parsed.body ?? record.body,
|
|
867
|
+
tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
|
|
868
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
869
|
+
};
|
|
870
|
+
await this.repository.insertEvent(event);
|
|
871
|
+
});
|
|
872
|
+
return await this.getPostRecord(parsed.postId);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private async archivePost(input: {
|
|
876
|
+
postId: string;
|
|
877
|
+
reason?: string;
|
|
878
|
+
idempotencyKey?: string;
|
|
879
|
+
}): Promise<PostRecord> {
|
|
880
|
+
this.checkAbort();
|
|
881
|
+
const record = await this.getPostRecord(input.postId);
|
|
882
|
+
this.assertPostAuthor(record);
|
|
883
|
+
if (record.status !== 'archived') {
|
|
884
|
+
await this.repository.transaction(async () => {
|
|
885
|
+
const event: SynomemEvent = {
|
|
886
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
887
|
+
type: 'post.archived',
|
|
888
|
+
postId: input.postId,
|
|
889
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
890
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
891
|
+
};
|
|
892
|
+
await this.repository.insertEvent(event);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
return await this.getPostRecord(input.postId);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
private async acknowledgePost(input: {
|
|
899
|
+
postId: string;
|
|
900
|
+
note?: string;
|
|
901
|
+
idempotencyKey?: string;
|
|
902
|
+
}): Promise<PostRecord> {
|
|
903
|
+
this.checkAbort();
|
|
904
|
+
const record = await this.getPostRecord(input.postId);
|
|
905
|
+
// Acknowledging twice is the same statement, so the second is a no-op
|
|
906
|
+
// rather than a second row or an error.
|
|
907
|
+
const already = record.acknowledgments.some(
|
|
908
|
+
(entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind,
|
|
909
|
+
);
|
|
910
|
+
if (!already) {
|
|
911
|
+
await this.repository.transaction(async () => {
|
|
912
|
+
const event: SynomemEvent = {
|
|
913
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
914
|
+
type: 'post.acknowledged',
|
|
915
|
+
postId: input.postId,
|
|
916
|
+
...(input.note ? { note: input.note } : {}),
|
|
917
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
918
|
+
};
|
|
919
|
+
await this.repository.insertEvent(event);
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
return await this.getPostRecord(input.postId);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private async withdrawPostAcknowledgment(input: {
|
|
926
|
+
postId: string;
|
|
927
|
+
reason?: string;
|
|
928
|
+
}): Promise<PostRecord> {
|
|
929
|
+
this.checkAbort();
|
|
930
|
+
const record = await this.getPostRecord(input.postId);
|
|
931
|
+
const mine = record.acknowledgments.some(
|
|
932
|
+
(entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind,
|
|
933
|
+
);
|
|
934
|
+
if (mine) {
|
|
935
|
+
await this.repository.transaction(async () => {
|
|
936
|
+
const event: SynomemEvent = {
|
|
937
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
938
|
+
type: 'post.acknowledgment.withdrawn',
|
|
939
|
+
postId: input.postId,
|
|
940
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
941
|
+
};
|
|
942
|
+
await this.repository.insertEvent(event);
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
return await this.getPostRecord(input.postId);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private async postRoster(postId: string): Promise<PostRoster> {
|
|
949
|
+
this.checkAbort();
|
|
950
|
+
await this.requireVisibleItem(postId, 'post');
|
|
951
|
+
const roster = await this.repository.postRoster(postId);
|
|
952
|
+
if (!roster) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${postId}`);
|
|
953
|
+
return roster;
|
|
954
|
+
}
|
|
955
|
+
|
|
618
956
|
private async getNoteRecord(id: string): Promise<NoteRecord> {
|
|
619
957
|
await this.requireVisibleItem(id, 'note');
|
|
620
958
|
const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
@@ -744,21 +1082,21 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
744
1082
|
return await this.getNoteRecord(input.noteId);
|
|
745
1083
|
}
|
|
746
1084
|
|
|
747
|
-
private async
|
|
748
|
-
await this.requireVisibleItem(id, '
|
|
749
|
-
const record =
|
|
750
|
-
if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown
|
|
1085
|
+
private async getTaskRecord(id: string): Promise<TaskRecord> {
|
|
1086
|
+
await this.requireVisibleItem(id, 'task');
|
|
1087
|
+
const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
1088
|
+
if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
|
|
751
1089
|
return record;
|
|
752
1090
|
}
|
|
753
|
-
private async
|
|
1091
|
+
private async getTask(id: string): Promise<TaskRecord> {
|
|
754
1092
|
this.checkAbort();
|
|
755
|
-
return await this.
|
|
1093
|
+
return await this.getTaskRecord(id);
|
|
756
1094
|
}
|
|
757
1095
|
|
|
758
|
-
private async
|
|
1096
|
+
private async createTask(input: CreateTaskInput): Promise<CreateTaskResult> {
|
|
759
1097
|
this.checkAbort();
|
|
760
1098
|
await this.repository.assertEventCompatibility();
|
|
761
|
-
const parsed = this.validate(() =>
|
|
1099
|
+
const parsed = this.validate(() => createTaskSchema.parse(input));
|
|
762
1100
|
const assigneeId =
|
|
763
1101
|
parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
|
|
764
1102
|
if (!assigneeId)
|
|
@@ -768,21 +1106,21 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
768
1106
|
);
|
|
769
1107
|
const assignee = await this.repository.getAgent(assigneeId);
|
|
770
1108
|
if (!assignee)
|
|
771
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown
|
|
1109
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
|
|
772
1110
|
if (
|
|
773
1111
|
this.actor.kind === 'agent' &&
|
|
774
1112
|
this.actor.id !== assignee.id &&
|
|
775
|
-
!this.repository.config.
|
|
1113
|
+
!this.repository.config.allowCrossAgentTasks
|
|
776
1114
|
)
|
|
777
|
-
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent
|
|
1115
|
+
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
|
|
778
1116
|
const outcome = await this.repository.transaction(async () => {
|
|
779
|
-
const prior = await this.priorMutation(parsed.idempotencyKey, '
|
|
780
|
-
if (prior?.type === '
|
|
1117
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
|
|
1118
|
+
if (prior?.type === 'task.created') return { id: prior.id, created: false };
|
|
781
1119
|
const id = this.nextId();
|
|
782
1120
|
const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
|
|
783
1121
|
const event: SynomemEvent = {
|
|
784
1122
|
...this.eventBase(id, 1, id),
|
|
785
|
-
type: '
|
|
1123
|
+
type: 'task.created',
|
|
786
1124
|
assigneeAgentId: assignee.id,
|
|
787
1125
|
assigneeDisplayName: assignee.displayName,
|
|
788
1126
|
title: parsed.title,
|
|
@@ -801,13 +1139,13 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
801
1139
|
});
|
|
802
1140
|
if (outcome.created) await this.projectionWriter.syncAgent(assignee.id);
|
|
803
1141
|
return {
|
|
804
|
-
record: await this.
|
|
1142
|
+
record: await this.getTaskRecord(outcome.id),
|
|
805
1143
|
created: outcome.created,
|
|
806
1144
|
deduplicated: !outcome.created,
|
|
807
1145
|
};
|
|
808
1146
|
}
|
|
809
1147
|
|
|
810
|
-
private
|
|
1148
|
+
private assertTaskParticipant(record: TaskRecord): void {
|
|
811
1149
|
const isCreator =
|
|
812
1150
|
record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
|
|
813
1151
|
const isAssignee =
|
|
@@ -815,35 +1153,35 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
815
1153
|
if (!this.administrative && !isCreator && !isAssignee)
|
|
816
1154
|
throw new SynomemError(
|
|
817
1155
|
'MUTATION_FORBIDDEN',
|
|
818
|
-
'Only the
|
|
1156
|
+
'Only the task creator, assignee, or a human administrator may change it.',
|
|
819
1157
|
);
|
|
820
1158
|
}
|
|
821
1159
|
|
|
822
|
-
private
|
|
1160
|
+
private assertTaskAssignee(record: TaskRecord): void {
|
|
823
1161
|
if (
|
|
824
1162
|
!this.administrative &&
|
|
825
1163
|
!(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)
|
|
826
1164
|
) {
|
|
827
1165
|
throw new SynomemError(
|
|
828
1166
|
'MUTATION_FORBIDDEN',
|
|
829
|
-
'Only the assigned agent or a human administrator may accept or reject this
|
|
1167
|
+
'Only the assigned agent or a human administrator may accept or reject this task.',
|
|
830
1168
|
);
|
|
831
1169
|
}
|
|
832
1170
|
}
|
|
833
1171
|
|
|
834
|
-
private async
|
|
835
|
-
const parsed = this.validate(() =>
|
|
836
|
-
const record = await this.
|
|
837
|
-
this.
|
|
1172
|
+
private async updateTask(input: UpdateTaskInput): Promise<TaskRecord> {
|
|
1173
|
+
const parsed = this.validate(() => updateTaskSchema.parse(input));
|
|
1174
|
+
const record = await this.getTaskRecord(parsed.taskId);
|
|
1175
|
+
this.assertTaskParticipant(record);
|
|
838
1176
|
if (record.status !== 'open')
|
|
839
|
-
throw new SynomemError('INVALID_INPUT', 'Only open
|
|
1177
|
+
throw new SynomemError('INVALID_INPUT', 'Only open tasks can be updated.');
|
|
840
1178
|
if (parsed.expectedVersion !== record.current.version)
|
|
841
1179
|
throw new SynomemError(
|
|
842
1180
|
'REVISION_CONFLICT',
|
|
843
|
-
`Expected
|
|
1181
|
+
`Expected task version ${parsed.expectedVersion}; current version is ${record.current.version}.`,
|
|
844
1182
|
);
|
|
845
1183
|
await this.repository.transaction(async () => {
|
|
846
|
-
const prior = await this.priorMutation(parsed.idempotencyKey, '
|
|
1184
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.updated');
|
|
847
1185
|
if (prior) return;
|
|
848
1186
|
if (
|
|
849
1187
|
(await this.repository.nextAggregateVersion(record.event.id)) !==
|
|
@@ -851,13 +1189,13 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
851
1189
|
)
|
|
852
1190
|
throw new SynomemError(
|
|
853
1191
|
'REVISION_CONFLICT',
|
|
854
|
-
'The
|
|
1192
|
+
'The task changed before this update was stored.',
|
|
855
1193
|
);
|
|
856
1194
|
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
857
1195
|
const event: SynomemEvent = {
|
|
858
1196
|
...this.eventBase(record.event.id, parsed.expectedVersion + 1),
|
|
859
|
-
type: '
|
|
860
|
-
|
|
1197
|
+
type: 'task.updated',
|
|
1198
|
+
taskId: record.event.id,
|
|
861
1199
|
title: parsed.title ?? record.current.title,
|
|
862
1200
|
...(parsed.description !== undefined
|
|
863
1201
|
? { description: parsed.description }
|
|
@@ -875,35 +1213,49 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
875
1213
|
await this.repository.insertEvent(event);
|
|
876
1214
|
});
|
|
877
1215
|
await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
|
|
878
|
-
return await this.
|
|
1216
|
+
return await this.getTaskRecord(parsed.taskId);
|
|
879
1217
|
}
|
|
880
1218
|
|
|
881
|
-
private async
|
|
882
|
-
input: {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1219
|
+
private async taskTransition(
|
|
1220
|
+
input: {
|
|
1221
|
+
taskId: string;
|
|
1222
|
+
idempotencyKey?: string;
|
|
1223
|
+
note?: string;
|
|
1224
|
+
reason?: string;
|
|
1225
|
+
response?: string;
|
|
1226
|
+
},
|
|
1227
|
+
type: 'task.accepted' | 'task.rejected' | 'task.completed' | 'task.reopened' | 'task.canceled',
|
|
1228
|
+
): Promise<TaskRecord> {
|
|
1229
|
+
const record = await this.getTaskRecord(input.taskId);
|
|
1230
|
+
// A rejection must say why. Enforced here as well as in the schema so the
|
|
1231
|
+
// failure names the missing thing rather than surfacing as a parse error.
|
|
1232
|
+
if (type === 'task.rejected' && !input.response?.trim()) {
|
|
1233
|
+
throw new SynomemError(
|
|
1234
|
+
'INVALID_INPUT',
|
|
1235
|
+
'Rejecting a task requires a response explaining why, so the assigner knows whether to reassign it, wait, or change the request.',
|
|
1236
|
+
);
|
|
892
1237
|
}
|
|
893
|
-
if (type === '
|
|
1238
|
+
if (type === 'task.accepted' || type === 'task.rejected') this.assertTaskAssignee(record);
|
|
1239
|
+
else this.assertTaskParticipant(record);
|
|
1240
|
+
if ((type === 'task.accepted' || type === 'task.rejected') && record.status !== 'assigned') {
|
|
1241
|
+
if (type === 'task.accepted' && record.status === 'open') return record;
|
|
1242
|
+
if (type === 'task.rejected' && record.status === 'rejected') return record;
|
|
1243
|
+
throw new SynomemError('INVALID_INPUT', 'Only assigned tasks may be accepted or rejected.');
|
|
1244
|
+
}
|
|
1245
|
+
if (type === 'task.completed' && record.status !== 'open') {
|
|
894
1246
|
if (record.status === 'completed') return record;
|
|
895
1247
|
throw new SynomemError(
|
|
896
1248
|
'INVALID_INPUT',
|
|
897
|
-
'Only accepted or self-created open
|
|
1249
|
+
'Only accepted or self-created open tasks may be completed.',
|
|
898
1250
|
);
|
|
899
1251
|
}
|
|
900
|
-
if (type === '
|
|
1252
|
+
if (type === 'task.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
|
|
901
1253
|
if (record.status === 'open') return record;
|
|
902
|
-
throw new SynomemError('INVALID_INPUT', 'Only completed or canceled
|
|
1254
|
+
throw new SynomemError('INVALID_INPUT', 'Only completed or canceled tasks may be reopened.');
|
|
903
1255
|
}
|
|
904
|
-
if (type === '
|
|
1256
|
+
if (type === 'task.canceled' && record.status !== 'assigned' && record.status !== 'open') {
|
|
905
1257
|
if (record.status === 'canceled') return record;
|
|
906
|
-
throw new SynomemError('INVALID_INPUT', 'Only assigned or open
|
|
1258
|
+
throw new SynomemError('INVALID_INPUT', 'Only assigned or open tasks may be canceled.');
|
|
907
1259
|
}
|
|
908
1260
|
await this.repository.transaction(async () => {
|
|
909
1261
|
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
@@ -913,34 +1265,180 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
913
1265
|
record.event.id,
|
|
914
1266
|
await this.repository.nextAggregateVersion(record.event.id),
|
|
915
1267
|
),
|
|
916
|
-
|
|
1268
|
+
taskId: record.event.id,
|
|
917
1269
|
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
918
1270
|
};
|
|
919
1271
|
const event: SynomemEvent =
|
|
920
|
-
type === '
|
|
1272
|
+
type === 'task.completed'
|
|
921
1273
|
? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
|
|
922
|
-
: type === '
|
|
923
|
-
? { ...base, type,
|
|
924
|
-
:
|
|
1274
|
+
: type === 'task.rejected'
|
|
1275
|
+
? { ...base, type, response: input.response!.trim() }
|
|
1276
|
+
: type === 'task.accepted'
|
|
1277
|
+
? {
|
|
1278
|
+
...base,
|
|
1279
|
+
type,
|
|
1280
|
+
...(input.response ? { response: input.response.trim() } : {}),
|
|
1281
|
+
}
|
|
1282
|
+
: type === 'task.canceled'
|
|
1283
|
+
? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
|
|
1284
|
+
: { ...base, type };
|
|
925
1285
|
await this.repository.insertEvent(event);
|
|
926
1286
|
});
|
|
927
1287
|
await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
|
|
928
|
-
return await this.
|
|
1288
|
+
return await this.getTaskRecord(input.taskId);
|
|
929
1289
|
}
|
|
930
|
-
private
|
|
931
|
-
return this.
|
|
1290
|
+
private completeTask(input: { taskId: string; note?: string; idempotencyKey?: string }) {
|
|
1291
|
+
return this.taskTransition(input, 'task.completed');
|
|
932
1292
|
}
|
|
933
|
-
private
|
|
934
|
-
return this.
|
|
1293
|
+
private acceptTask(input: { taskId: string; response?: string; idempotencyKey?: string }) {
|
|
1294
|
+
return this.taskTransition(input, 'task.accepted');
|
|
935
1295
|
}
|
|
936
|
-
private
|
|
937
|
-
return this.
|
|
1296
|
+
private rejectTask(input: { taskId: string; response: string; idempotencyKey?: string }) {
|
|
1297
|
+
return this.taskTransition(input, 'task.rejected');
|
|
938
1298
|
}
|
|
939
|
-
private
|
|
940
|
-
return this.
|
|
1299
|
+
private reopenTask(input: { taskId: string; idempotencyKey?: string }) {
|
|
1300
|
+
return this.taskTransition(input, 'task.reopened');
|
|
941
1301
|
}
|
|
942
|
-
private
|
|
943
|
-
return this.
|
|
1302
|
+
private cancelTask(input: { taskId: string; reason?: string; idempotencyKey?: string }) {
|
|
1303
|
+
return this.taskTransition(input, 'task.canceled');
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/* ----------------------------------------------------------------- todos *
|
|
1307
|
+
* A Todo is a private reminder an agent creates for itself. There is no
|
|
1308
|
+
* assignee, no acceptance, and no visibility choice: it belongs to its author
|
|
1309
|
+
* and only its author reads it. Every method below asserts that ownership
|
|
1310
|
+
* rather than relying on a visibility filter, so a Todo cannot be reached by
|
|
1311
|
+
* guessing its id.
|
|
1312
|
+
*/
|
|
1313
|
+
|
|
1314
|
+
private async createTodo(input: CreateTodoInput): Promise<CreateTodoResult> {
|
|
1315
|
+
this.checkAbort();
|
|
1316
|
+
await this.repository.assertEventCompatibility();
|
|
1317
|
+
const parsed = this.validate(() => createTodoSchema.parse(input));
|
|
1318
|
+
const outcome = await this.repository.transaction(async () => {
|
|
1319
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
|
|
1320
|
+
if (prior?.type === 'todo.created') return { id: prior.id, created: false };
|
|
1321
|
+
const id = this.nextId();
|
|
1322
|
+
const event: SynomemEvent = {
|
|
1323
|
+
...this.eventBase(id, 1, id),
|
|
1324
|
+
type: 'todo.created',
|
|
1325
|
+
title: parsed.title,
|
|
1326
|
+
...(parsed.details !== undefined ? { details: parsed.details } : {}),
|
|
1327
|
+
priority: parsed.priority ?? 3,
|
|
1328
|
+
...(parsed.due ? { due: parsed.due } : {}),
|
|
1329
|
+
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
1330
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
1331
|
+
...(parsed.source ? { source: parsed.source } : {}),
|
|
1332
|
+
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
1333
|
+
};
|
|
1334
|
+
await this.repository.insertEvent(event);
|
|
1335
|
+
return { id, created: true };
|
|
1336
|
+
});
|
|
1337
|
+
return {
|
|
1338
|
+
record: await this.getTodoRecord(outcome.id),
|
|
1339
|
+
created: outcome.created,
|
|
1340
|
+
deduplicated: !outcome.created,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private async getTodoRecord(id: string): Promise<TodoRecord> {
|
|
1345
|
+
const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
1346
|
+
if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
|
|
1347
|
+
this.assertTodoOwner(record);
|
|
1348
|
+
return record;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Todos are owner-only. No role, however broad, reads another actor's
|
|
1353
|
+
* private reminders — that is a named administrative capability this release
|
|
1354
|
+
* does not have, not something a permission check quietly allows.
|
|
1355
|
+
*/
|
|
1356
|
+
private assertTodoOwner(record: TodoRecord): void {
|
|
1357
|
+
const owner = record.event.actor;
|
|
1358
|
+
if (owner.kind !== this.actor.kind || owner.id !== this.actor.id) {
|
|
1359
|
+
throw new SynomemError(
|
|
1360
|
+
'MUTATION_FORBIDDEN',
|
|
1361
|
+
'A todo is private to the actor who created it.',
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
private async getTodo(id: string): Promise<TodoRecord> {
|
|
1367
|
+
this.checkAbort();
|
|
1368
|
+
return await this.getTodoRecord(id);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
private async updateTodo(input: UpdateTodoInput): Promise<TodoRecord> {
|
|
1372
|
+
this.checkAbort();
|
|
1373
|
+
const parsed = this.validate(() => updateTodoSchema.parse(input));
|
|
1374
|
+
const record = await this.getTodoRecord(parsed.todoId);
|
|
1375
|
+
if (record.current.version !== parsed.expectedVersion) {
|
|
1376
|
+
throw new SynomemError(
|
|
1377
|
+
'REVISION_CONFLICT',
|
|
1378
|
+
`Todo ${parsed.todoId} is at version ${record.current.version}.`,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
1382
|
+
await this.repository.transaction(async () => {
|
|
1383
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
|
|
1384
|
+
if (prior) return;
|
|
1385
|
+
const event: SynomemEvent = {
|
|
1386
|
+
...this.eventBase(
|
|
1387
|
+
record.event.id,
|
|
1388
|
+
await this.repository.nextAggregateVersion(record.event.id),
|
|
1389
|
+
),
|
|
1390
|
+
type: 'todo.updated',
|
|
1391
|
+
todoId: record.event.id,
|
|
1392
|
+
title: parsed.title ?? record.current.title,
|
|
1393
|
+
...((parsed.details ?? record.current.details)
|
|
1394
|
+
? { details: parsed.details ?? record.current.details }
|
|
1395
|
+
: {}),
|
|
1396
|
+
priority: parsed.priority ?? record.current.priority,
|
|
1397
|
+
...(due ? { due } : {}),
|
|
1398
|
+
tags: [...new Set<string>(parsed.tags ?? record.current.tags)].sort(),
|
|
1399
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
1400
|
+
};
|
|
1401
|
+
await this.repository.insertEvent(event);
|
|
1402
|
+
});
|
|
1403
|
+
return await this.getTodoRecord(parsed.todoId);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
private async todoTransition(
|
|
1407
|
+
input: { todoId: string; idempotencyKey?: string; note?: string; reason?: string },
|
|
1408
|
+
type: 'todo.completed' | 'todo.reopened' | 'todo.canceled' | 'todo.archived',
|
|
1409
|
+
): Promise<TodoRecord> {
|
|
1410
|
+
const record = await this.getTodoRecord(input.todoId);
|
|
1411
|
+
if (type === 'todo.completed' && record.status !== 'open') {
|
|
1412
|
+
if (record.status === 'completed') return record;
|
|
1413
|
+
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
|
|
1414
|
+
}
|
|
1415
|
+
if (type === 'todo.reopened' && record.status === 'open') return record;
|
|
1416
|
+
if (type === 'todo.canceled' && record.status !== 'open') {
|
|
1417
|
+
if (record.status === 'canceled') return record;
|
|
1418
|
+
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
|
|
1419
|
+
}
|
|
1420
|
+
if (type === 'todo.archived' && record.status === 'archived') return record;
|
|
1421
|
+
|
|
1422
|
+
await this.repository.transaction(async () => {
|
|
1423
|
+
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
1424
|
+
if (prior) return;
|
|
1425
|
+
const base = {
|
|
1426
|
+
...this.eventBase(
|
|
1427
|
+
record.event.id,
|
|
1428
|
+
await this.repository.nextAggregateVersion(record.event.id),
|
|
1429
|
+
),
|
|
1430
|
+
todoId: record.event.id,
|
|
1431
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
1432
|
+
};
|
|
1433
|
+
const event: SynomemEvent =
|
|
1434
|
+
type === 'todo.completed'
|
|
1435
|
+
? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
|
|
1436
|
+
: type === 'todo.canceled'
|
|
1437
|
+
? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
|
|
1438
|
+
: { ...base, type };
|
|
1439
|
+
await this.repository.insertEvent(event);
|
|
1440
|
+
});
|
|
1441
|
+
return await this.getTodoRecord(input.todoId);
|
|
944
1442
|
}
|
|
945
1443
|
|
|
946
1444
|
private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
|
|
@@ -976,7 +1474,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
976
1474
|
if (summary.kind === 'kudos') return this.getKudos(id);
|
|
977
1475
|
if (summary.kind === 'memo') return this.getMemo(id);
|
|
978
1476
|
if (summary.kind === 'note') return this.getNote(id);
|
|
979
|
-
return this.
|
|
1477
|
+
return this.getTask(id);
|
|
980
1478
|
}
|
|
981
1479
|
|
|
982
1480
|
async stats(input: KudosListInput = {}): Promise<KudosStats> {
|
|
@@ -1035,6 +1533,15 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1035
1533
|
}
|
|
1036
1534
|
}
|
|
1037
1535
|
|
|
1536
|
+
/**
|
|
1537
|
+
* The schema version this package writes and expects. Named rather than
|
|
1538
|
+
* repeated as a literal because it is asserted in three places, and a doctor
|
|
1539
|
+
* check that silently lags the migration runner reports a healthy database as
|
|
1540
|
+
* broken.
|
|
1541
|
+
*/
|
|
1542
|
+
const CURRENT_SCHEMA_VERSION = 6;
|
|
1543
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6];
|
|
1544
|
+
|
|
1038
1545
|
export class SynomemClient extends SynomemCore implements SynomemService {
|
|
1039
1546
|
readonly home: string;
|
|
1040
1547
|
readonly storage: SynomemStorage;
|
|
@@ -1133,8 +1640,9 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1133
1640
|
});
|
|
1134
1641
|
const migrationState = this.storage.migrationState();
|
|
1135
1642
|
const migrationsValid =
|
|
1136
|
-
migrationState.schemaVersion ===
|
|
1137
|
-
JSON.stringify(migrationState.appliedVersions) ===
|
|
1643
|
+
migrationState.schemaVersion === CURRENT_SCHEMA_VERSION &&
|
|
1644
|
+
JSON.stringify(migrationState.appliedVersions) ===
|
|
1645
|
+
JSON.stringify(EXPECTED_APPLIED_MIGRATIONS);
|
|
1138
1646
|
diagnostics.push({
|
|
1139
1647
|
level: migrationsValid ? 'ok' : 'error',
|
|
1140
1648
|
code: migrationsValid ? 'MIGRATIONS_VALID' : 'MIGRATIONS_INCONSISTENT',
|
|
@@ -1201,7 +1709,7 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1201
1709
|
const records = recordsFromEvents(events);
|
|
1202
1710
|
const memos = memoRecordsFromEvents(events);
|
|
1203
1711
|
const notes = noteRecordsFromEvents(events);
|
|
1204
|
-
const
|
|
1712
|
+
const tasks = taskRecordsFromEvents(events);
|
|
1205
1713
|
const warning = scan.invalid.length
|
|
1206
1714
|
? `> Warning: ${scan.invalid.length} unsupported or malformed event(s) omitted from this Markdown view: ${scan.invalid.map((item) => item.id).join(', ')}\n\n`
|
|
1207
1715
|
: '';
|
|
@@ -1218,9 +1726,9 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1218
1726
|
(record) =>
|
|
1219
1727
|
`## Note: ${escapeMarkdown(record.current.title)}\n\n${escapeMarkdown(record.current.body)}\n\nStatus: ${record.status}; version ${record.current.version}\n\nID: \`${record.event.id}\``,
|
|
1220
1728
|
),
|
|
1221
|
-
...
|
|
1729
|
+
...tasks.map(
|
|
1222
1730
|
(record) =>
|
|
1223
|
-
`##
|
|
1731
|
+
`## Task: ${escapeMarkdown(record.current.title)}\n\n${record.current.description ? `${escapeMarkdown(record.current.description)}\n\n` : ''}Status: ${record.status}; priority ${record.current.priority}\n\nID: \`${record.event.id}\``,
|
|
1224
1732
|
),
|
|
1225
1733
|
];
|
|
1226
1734
|
return `${warning}${sections.join('\n\n')}\n`;
|