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/dist/client.js
CHANGED
|
@@ -4,8 +4,8 @@ import { ulid } from 'ulid';
|
|
|
4
4
|
import { resolveHome } from './config.js';
|
|
5
5
|
import { asSynomemError, SynomemError } from './errors.js';
|
|
6
6
|
import { assertNoSymlinkEscape } from './fs-utils.js';
|
|
7
|
-
import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, ProjectionManager, recordsFromEvents, todoRecordsFromEvents, } from './projections.js';
|
|
8
|
-
import { actorSchema,
|
|
7
|
+
import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, postRecordsFromEvents, ProjectionManager, recordsFromEvents, taskRecordsFromEvents, todoRecordsFromEvents, } from './projections.js';
|
|
8
|
+
import { actorSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createPostSchema, updatePostSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, } from './schemas.js';
|
|
9
9
|
import { SynomemStorage } from './storage.js';
|
|
10
10
|
export class SynomemCore {
|
|
11
11
|
actor;
|
|
@@ -21,6 +21,11 @@ export class SynomemCore {
|
|
|
21
21
|
update: (id, changes) => this.updateAgent(id, changes),
|
|
22
22
|
get: (idOrAlias) => this.getAgent(idOrAlias),
|
|
23
23
|
list: () => this.listAgents(),
|
|
24
|
+
resolve: (query) => this.resolveAgent(query),
|
|
25
|
+
directory: () => this.agentDirectory(),
|
|
26
|
+
bindings: (idOrAlias) => this.listRuntimeBindings(idOrAlias),
|
|
27
|
+
bindRuntime: (input) => this.bindRuntime(input),
|
|
28
|
+
unbindRuntime: (bindingId) => this.unbindRuntime(bindingId),
|
|
24
29
|
};
|
|
25
30
|
kudos = {
|
|
26
31
|
give: (input) => this.giveKudos(input),
|
|
@@ -37,6 +42,22 @@ export class SynomemCore {
|
|
|
37
42
|
read: (input) => this.readMemo(input),
|
|
38
43
|
archive: (input) => this.archiveMemo(input),
|
|
39
44
|
};
|
|
45
|
+
posts = {
|
|
46
|
+
create: (input) => this.createPost(input),
|
|
47
|
+
list: (input = {}) => this.listItems({ ...input, kinds: ['post'] }),
|
|
48
|
+
get: (id) => this.getPost(id),
|
|
49
|
+
update: (input) => this.updatePost(input),
|
|
50
|
+
archive: (input) => this.archivePost(input),
|
|
51
|
+
/*
|
|
52
|
+
* Acknowledging is an explicit call and always speaks for the caller alone.
|
|
53
|
+
* There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
|
|
54
|
+
* is one actor saying "I have seen this", and reading a post must never
|
|
55
|
+
* append one, or the roster stops meaning anything.
|
|
56
|
+
*/
|
|
57
|
+
acknowledge: (input) => this.acknowledgePost(input),
|
|
58
|
+
withdrawAcknowledgment: (input) => this.withdrawPostAcknowledgment(input),
|
|
59
|
+
roster: (postId) => this.postRoster(postId),
|
|
60
|
+
};
|
|
40
61
|
notes = {
|
|
41
62
|
create: (input) => this.createNote(input),
|
|
42
63
|
list: (input = {}) => this.listItems({ ...input, kinds: ['note'] }),
|
|
@@ -44,16 +65,59 @@ export class SynomemCore {
|
|
|
44
65
|
revise: (input) => this.reviseNote(input),
|
|
45
66
|
archive: (input) => this.archiveNote(input),
|
|
46
67
|
};
|
|
68
|
+
tasks = {
|
|
69
|
+
create: (input) => this.createTask(input),
|
|
70
|
+
list: (input = {}) => this.listItems({ ...input, kinds: ['task'] }),
|
|
71
|
+
get: (id) => this.getTask(id),
|
|
72
|
+
update: (input) => this.updateTask(input),
|
|
73
|
+
// A response is optional when accepting and required when rejecting: a
|
|
74
|
+
// refusal without a reason leaves the assigner unable to act on it.
|
|
75
|
+
accept: (input) => this.acceptTask(input),
|
|
76
|
+
reject: (input) => this.rejectTask(input),
|
|
77
|
+
complete: (input) => this.completeTask(input),
|
|
78
|
+
reopen: (input) => this.reopenTask(input),
|
|
79
|
+
cancel: (input) => this.cancelTask(input),
|
|
80
|
+
};
|
|
47
81
|
todos = {
|
|
48
82
|
create: (input) => this.createTodo(input),
|
|
49
83
|
list: (input = {}) => this.listItems({ ...input, kinds: ['todo'] }),
|
|
50
84
|
get: (id) => this.getTodo(id),
|
|
51
85
|
update: (input) => this.updateTodo(input),
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
86
|
+
complete: (input) => this.todoTransition(input, 'todo.completed'),
|
|
87
|
+
reopen: (input) => this.todoTransition(input, 'todo.reopened'),
|
|
88
|
+
cancel: (input) => this.todoTransition(input, 'todo.canceled'),
|
|
89
|
+
archive: (input) => this.todoTransition(input, 'todo.archived'),
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Unanswered and overdue discovery.
|
|
93
|
+
*
|
|
94
|
+
* The plan asks for shared work to be observable without treating runtime
|
|
95
|
+
* metadata as a delivery guarantee. These are query states derived from
|
|
96
|
+
* durable events: they say nobody has answered yet, not that the agent was
|
|
97
|
+
* offline, missed a notification, or lacks a capability it claimed.
|
|
98
|
+
*/
|
|
99
|
+
discovery = {
|
|
100
|
+
/**
|
|
101
|
+
* Tasks awaiting acceptance, unread memos, and unacknowledged kudos —
|
|
102
|
+
* optionally only those older than a given age or instant.
|
|
103
|
+
*/
|
|
104
|
+
unanswered: (input = {}) => {
|
|
105
|
+
const { olderThanHours, awaitingSince, ...rest } = input;
|
|
106
|
+
const since = awaitingSince ??
|
|
107
|
+
(olderThanHours !== undefined
|
|
108
|
+
? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
|
|
109
|
+
: undefined);
|
|
110
|
+
return this.listItems({
|
|
111
|
+
...rest,
|
|
112
|
+
awaitingResponse: true,
|
|
113
|
+
...(since ? { awaitingSince: since } : {}),
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
/** Open work whose deadline has passed. Defaults to "now". */
|
|
117
|
+
overdue: (input = {}) => {
|
|
118
|
+
const { asOf, ...rest } = input;
|
|
119
|
+
return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
|
|
120
|
+
},
|
|
57
121
|
};
|
|
58
122
|
items = {
|
|
59
123
|
list: (input = {}) => this.listItems(input),
|
|
@@ -152,7 +216,7 @@ export class SynomemCore {
|
|
|
152
216
|
async updateAgent(idOrAlias, changes) {
|
|
153
217
|
this.checkAbort();
|
|
154
218
|
await this.repository.assertEventCompatibility();
|
|
155
|
-
this.validate(() =>
|
|
219
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
156
220
|
const parsed = this.validate(() => updateAgentSchema.parse(changes));
|
|
157
221
|
const existing = await this.repository.getAgent(idOrAlias);
|
|
158
222
|
if (!existing)
|
|
@@ -188,7 +252,7 @@ export class SynomemCore {
|
|
|
188
252
|
}
|
|
189
253
|
async getAgent(idOrAlias) {
|
|
190
254
|
this.checkAbort();
|
|
191
|
-
this.validate(() =>
|
|
255
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
192
256
|
const profile = await this.repository.getAgent(idOrAlias);
|
|
193
257
|
if (!profile)
|
|
194
258
|
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
@@ -198,6 +262,71 @@ export class SynomemCore {
|
|
|
198
262
|
this.checkAbort();
|
|
199
263
|
return await this.repository.listAgents();
|
|
200
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Resolves a name without ever choosing between equally valid answers.
|
|
267
|
+
*
|
|
268
|
+
* Callers that want a single agent should treat an empty `match` as a
|
|
269
|
+
* question for the user, not as "not found": `candidates` distinguishes the
|
|
270
|
+
* two cases.
|
|
271
|
+
*/
|
|
272
|
+
async resolveAgent(query) {
|
|
273
|
+
this.checkAbort();
|
|
274
|
+
const trimmed = query.trim();
|
|
275
|
+
if (!trimmed)
|
|
276
|
+
throw new SynomemError('INVALID_INPUT', 'A lookup name is required.');
|
|
277
|
+
const resolved = await this.repository.resolveAgent(trimmed);
|
|
278
|
+
return {
|
|
279
|
+
query: trimmed,
|
|
280
|
+
...(resolved.match ? { match: resolved.match } : {}),
|
|
281
|
+
candidates: resolved.candidates,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
async agentDirectory() {
|
|
285
|
+
this.checkAbort();
|
|
286
|
+
const profiles = await this.repository.listAgents();
|
|
287
|
+
const entries = [];
|
|
288
|
+
for (const profile of profiles) {
|
|
289
|
+
entries.push({
|
|
290
|
+
profile,
|
|
291
|
+
runtimeBindings: await this.repository.listRuntimeBindings(profile.id),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return entries;
|
|
295
|
+
}
|
|
296
|
+
async listRuntimeBindings(idOrAlias) {
|
|
297
|
+
const profile = await this.getAgent(idOrAlias);
|
|
298
|
+
return await this.repository.listRuntimeBindings(profile.id);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Records where an agent runs. Re-binding the same runtime and profile
|
|
302
|
+
* updates the claim in place rather than accumulating duplicates, because a
|
|
303
|
+
* reinstall is the same agent in the same place, not a second one.
|
|
304
|
+
*/
|
|
305
|
+
async bindRuntime(input) {
|
|
306
|
+
this.checkAbort();
|
|
307
|
+
const parsed = this.validate(() => bindRuntimeSchema.parse(input));
|
|
308
|
+
const profile = await this.getAgent(parsed.agentId);
|
|
309
|
+
await this.repository.bindRuntime({
|
|
310
|
+
id: this.idGenerator(),
|
|
311
|
+
agentId: profile.id,
|
|
312
|
+
...(parsed.installationId !== undefined ? { installationId: parsed.installationId } : {}),
|
|
313
|
+
runtime: parsed.runtime,
|
|
314
|
+
...(parsed.profile !== undefined ? { profile: parsed.profile } : {}),
|
|
315
|
+
...(parsed.capabilities !== undefined ? { capabilities: parsed.capabilities } : {}),
|
|
316
|
+
boundAt: this.now(),
|
|
317
|
+
});
|
|
318
|
+
const bindings = await this.repository.listRuntimeBindings(profile.id);
|
|
319
|
+
const binding = bindings.find((candidate) => candidate.runtime === parsed.runtime &&
|
|
320
|
+
(candidate.profile ?? '') === (parsed.profile ?? '') &&
|
|
321
|
+
(candidate.installationId ?? '') === (parsed.installationId ?? ''));
|
|
322
|
+
if (!binding)
|
|
323
|
+
throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
|
|
324
|
+
return binding;
|
|
325
|
+
}
|
|
326
|
+
async unbindRuntime(bindingId) {
|
|
327
|
+
this.checkAbort();
|
|
328
|
+
return await this.repository.unbindRuntime(bindingId);
|
|
329
|
+
}
|
|
201
330
|
async giveKudos(input) {
|
|
202
331
|
this.checkAbort();
|
|
203
332
|
await this.repository.assertEventCompatibility();
|
|
@@ -351,7 +480,7 @@ export class SynomemCore {
|
|
|
351
480
|
? 'MEMO_NOT_FOUND'
|
|
352
481
|
: kind === 'note'
|
|
353
482
|
? 'NOTE_NOT_FOUND'
|
|
354
|
-
: kind === '
|
|
483
|
+
: kind === 'task'
|
|
355
484
|
? 'TODO_NOT_FOUND'
|
|
356
485
|
: 'KUDOS_NOT_FOUND';
|
|
357
486
|
throw new SynomemError(code, `Unknown ${kind}: ${id}`);
|
|
@@ -456,6 +585,146 @@ export class SynomemCore {
|
|
|
456
585
|
await this.projectionWriter.syncAgent(record.event.recipientAgentId);
|
|
457
586
|
return await this.getMemoRecord(input.memoId);
|
|
458
587
|
}
|
|
588
|
+
async getPostRecord(id) {
|
|
589
|
+
await this.requireVisibleItem(id, 'post');
|
|
590
|
+
const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
591
|
+
if (!record)
|
|
592
|
+
throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
|
|
593
|
+
return record;
|
|
594
|
+
}
|
|
595
|
+
async getPost(id) {
|
|
596
|
+
this.checkAbort();
|
|
597
|
+
return await this.getPostRecord(id);
|
|
598
|
+
}
|
|
599
|
+
async createPost(input) {
|
|
600
|
+
this.checkAbort();
|
|
601
|
+
await this.repository.assertEventCompatibility();
|
|
602
|
+
const parsed = this.validate(() => createPostSchema.parse(input));
|
|
603
|
+
// A reply inherits its parent's workspace by construction, and cannot name
|
|
604
|
+
// a different target — there is no target to name.
|
|
605
|
+
if (parsed.replyTo)
|
|
606
|
+
await this.requireVisibleItem(parsed.replyTo, 'post');
|
|
607
|
+
const outcome = await this.repository.transaction(async () => {
|
|
608
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
|
|
609
|
+
if (prior?.type === 'post.created')
|
|
610
|
+
return { id: prior.id, created: false };
|
|
611
|
+
const id = this.nextId();
|
|
612
|
+
const event = {
|
|
613
|
+
...this.eventBase(id, 1, id),
|
|
614
|
+
type: 'post.created',
|
|
615
|
+
title: parsed.title,
|
|
616
|
+
body: parsed.body,
|
|
617
|
+
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
618
|
+
...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
|
|
619
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
620
|
+
...(parsed.source ? { source: parsed.source } : {}),
|
|
621
|
+
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
622
|
+
};
|
|
623
|
+
await this.repository.insertEvent(event);
|
|
624
|
+
return { id, created: true };
|
|
625
|
+
});
|
|
626
|
+
return {
|
|
627
|
+
record: await this.getPostRecord(outcome.id),
|
|
628
|
+
created: outcome.created,
|
|
629
|
+
deduplicated: !outcome.created,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/** Only the author edits a post. Everyone else responds to it. */
|
|
633
|
+
assertPostAuthor(record) {
|
|
634
|
+
if (this.administrative)
|
|
635
|
+
return;
|
|
636
|
+
if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
|
|
637
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
async updatePost(input) {
|
|
641
|
+
this.checkAbort();
|
|
642
|
+
const parsed = this.validate(() => updatePostSchema.parse(input));
|
|
643
|
+
const record = await this.getPostRecord(parsed.postId);
|
|
644
|
+
this.assertPostAuthor(record);
|
|
645
|
+
if (record.status === 'archived') {
|
|
646
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'An archived post cannot be edited.');
|
|
647
|
+
}
|
|
648
|
+
if (record.version !== parsed.expectedVersion) {
|
|
649
|
+
throw new SynomemError('REVISION_CONFLICT', `Post ${parsed.postId} is at version ${record.version}.`);
|
|
650
|
+
}
|
|
651
|
+
await this.repository.transaction(async () => {
|
|
652
|
+
const event = {
|
|
653
|
+
...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
|
|
654
|
+
type: 'post.edited',
|
|
655
|
+
postId: parsed.postId,
|
|
656
|
+
title: parsed.title ?? record.title,
|
|
657
|
+
body: parsed.body ?? record.body,
|
|
658
|
+
tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
|
|
659
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
660
|
+
};
|
|
661
|
+
await this.repository.insertEvent(event);
|
|
662
|
+
});
|
|
663
|
+
return await this.getPostRecord(parsed.postId);
|
|
664
|
+
}
|
|
665
|
+
async archivePost(input) {
|
|
666
|
+
this.checkAbort();
|
|
667
|
+
const record = await this.getPostRecord(input.postId);
|
|
668
|
+
this.assertPostAuthor(record);
|
|
669
|
+
if (record.status !== 'archived') {
|
|
670
|
+
await this.repository.transaction(async () => {
|
|
671
|
+
const event = {
|
|
672
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
673
|
+
type: 'post.archived',
|
|
674
|
+
postId: input.postId,
|
|
675
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
676
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
677
|
+
};
|
|
678
|
+
await this.repository.insertEvent(event);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return await this.getPostRecord(input.postId);
|
|
682
|
+
}
|
|
683
|
+
async acknowledgePost(input) {
|
|
684
|
+
this.checkAbort();
|
|
685
|
+
const record = await this.getPostRecord(input.postId);
|
|
686
|
+
// Acknowledging twice is the same statement, so the second is a no-op
|
|
687
|
+
// rather than a second row or an error.
|
|
688
|
+
const already = record.acknowledgments.some((entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind);
|
|
689
|
+
if (!already) {
|
|
690
|
+
await this.repository.transaction(async () => {
|
|
691
|
+
const event = {
|
|
692
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
693
|
+
type: 'post.acknowledged',
|
|
694
|
+
postId: input.postId,
|
|
695
|
+
...(input.note ? { note: input.note } : {}),
|
|
696
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
697
|
+
};
|
|
698
|
+
await this.repository.insertEvent(event);
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
return await this.getPostRecord(input.postId);
|
|
702
|
+
}
|
|
703
|
+
async withdrawPostAcknowledgment(input) {
|
|
704
|
+
this.checkAbort();
|
|
705
|
+
const record = await this.getPostRecord(input.postId);
|
|
706
|
+
const mine = record.acknowledgments.some((entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind);
|
|
707
|
+
if (mine) {
|
|
708
|
+
await this.repository.transaction(async () => {
|
|
709
|
+
const event = {
|
|
710
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
711
|
+
type: 'post.acknowledgment.withdrawn',
|
|
712
|
+
postId: input.postId,
|
|
713
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
714
|
+
};
|
|
715
|
+
await this.repository.insertEvent(event);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return await this.getPostRecord(input.postId);
|
|
719
|
+
}
|
|
720
|
+
async postRoster(postId) {
|
|
721
|
+
this.checkAbort();
|
|
722
|
+
await this.requireVisibleItem(postId, 'post');
|
|
723
|
+
const roster = await this.repository.postRoster(postId);
|
|
724
|
+
if (!roster)
|
|
725
|
+
throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${postId}`);
|
|
726
|
+
return roster;
|
|
727
|
+
}
|
|
459
728
|
async getNoteRecord(id) {
|
|
460
729
|
await this.requireVisibleItem(id, 'note');
|
|
461
730
|
const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
@@ -567,40 +836,40 @@ export class SynomemCore {
|
|
|
567
836
|
await this.projectionWriter.syncAgent(record.event.ownerAgentId);
|
|
568
837
|
return await this.getNoteRecord(input.noteId);
|
|
569
838
|
}
|
|
570
|
-
async
|
|
571
|
-
await this.requireVisibleItem(id, '
|
|
572
|
-
const record =
|
|
839
|
+
async getTaskRecord(id) {
|
|
840
|
+
await this.requireVisibleItem(id, 'task');
|
|
841
|
+
const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
573
842
|
if (!record)
|
|
574
|
-
throw new SynomemError('TODO_NOT_FOUND', `Unknown
|
|
843
|
+
throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
|
|
575
844
|
return record;
|
|
576
845
|
}
|
|
577
|
-
async
|
|
846
|
+
async getTask(id) {
|
|
578
847
|
this.checkAbort();
|
|
579
|
-
return await this.
|
|
848
|
+
return await this.getTaskRecord(id);
|
|
580
849
|
}
|
|
581
|
-
async
|
|
850
|
+
async createTask(input) {
|
|
582
851
|
this.checkAbort();
|
|
583
852
|
await this.repository.assertEventCompatibility();
|
|
584
|
-
const parsed = this.validate(() =>
|
|
853
|
+
const parsed = this.validate(() => createTaskSchema.parse(input));
|
|
585
854
|
const assigneeId = parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
|
|
586
855
|
if (!assigneeId)
|
|
587
856
|
throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify assigneeAgentId.');
|
|
588
857
|
const assignee = await this.repository.getAgent(assigneeId);
|
|
589
858
|
if (!assignee)
|
|
590
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown
|
|
859
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
|
|
591
860
|
if (this.actor.kind === 'agent' &&
|
|
592
861
|
this.actor.id !== assignee.id &&
|
|
593
|
-
!this.repository.config.
|
|
594
|
-
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent
|
|
862
|
+
!this.repository.config.allowCrossAgentTasks)
|
|
863
|
+
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
|
|
595
864
|
const outcome = await this.repository.transaction(async () => {
|
|
596
|
-
const prior = await this.priorMutation(parsed.idempotencyKey, '
|
|
597
|
-
if (prior?.type === '
|
|
865
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
|
|
866
|
+
if (prior?.type === 'task.created')
|
|
598
867
|
return { id: prior.id, created: false };
|
|
599
868
|
const id = this.nextId();
|
|
600
869
|
const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
|
|
601
870
|
const event = {
|
|
602
871
|
...this.eventBase(id, 1, id),
|
|
603
|
-
type: '
|
|
872
|
+
type: 'task.created',
|
|
604
873
|
assigneeAgentId: assignee.id,
|
|
605
874
|
assigneeDisplayName: assignee.displayName,
|
|
606
875
|
title: parsed.title,
|
|
@@ -620,43 +889,43 @@ export class SynomemCore {
|
|
|
620
889
|
if (outcome.created)
|
|
621
890
|
await this.projectionWriter.syncAgent(assignee.id);
|
|
622
891
|
return {
|
|
623
|
-
record: await this.
|
|
892
|
+
record: await this.getTaskRecord(outcome.id),
|
|
624
893
|
created: outcome.created,
|
|
625
894
|
deduplicated: !outcome.created,
|
|
626
895
|
};
|
|
627
896
|
}
|
|
628
|
-
|
|
897
|
+
assertTaskParticipant(record) {
|
|
629
898
|
const isCreator = record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
|
|
630
899
|
const isAssignee = this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId;
|
|
631
900
|
if (!this.administrative && !isCreator && !isAssignee)
|
|
632
|
-
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the
|
|
901
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the task creator, assignee, or a human administrator may change it.');
|
|
633
902
|
}
|
|
634
|
-
|
|
903
|
+
assertTaskAssignee(record) {
|
|
635
904
|
if (!this.administrative &&
|
|
636
905
|
!(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)) {
|
|
637
|
-
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assigned agent or a human administrator may accept or reject this
|
|
906
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assigned agent or a human administrator may accept or reject this task.');
|
|
638
907
|
}
|
|
639
908
|
}
|
|
640
|
-
async
|
|
641
|
-
const parsed = this.validate(() =>
|
|
642
|
-
const record = await this.
|
|
643
|
-
this.
|
|
909
|
+
async updateTask(input) {
|
|
910
|
+
const parsed = this.validate(() => updateTaskSchema.parse(input));
|
|
911
|
+
const record = await this.getTaskRecord(parsed.taskId);
|
|
912
|
+
this.assertTaskParticipant(record);
|
|
644
913
|
if (record.status !== 'open')
|
|
645
|
-
throw new SynomemError('INVALID_INPUT', 'Only open
|
|
914
|
+
throw new SynomemError('INVALID_INPUT', 'Only open tasks can be updated.');
|
|
646
915
|
if (parsed.expectedVersion !== record.current.version)
|
|
647
|
-
throw new SynomemError('REVISION_CONFLICT', `Expected
|
|
916
|
+
throw new SynomemError('REVISION_CONFLICT', `Expected task version ${parsed.expectedVersion}; current version is ${record.current.version}.`);
|
|
648
917
|
await this.repository.transaction(async () => {
|
|
649
|
-
const prior = await this.priorMutation(parsed.idempotencyKey, '
|
|
918
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.updated');
|
|
650
919
|
if (prior)
|
|
651
920
|
return;
|
|
652
921
|
if ((await this.repository.nextAggregateVersion(record.event.id)) !==
|
|
653
922
|
parsed.expectedVersion + 1)
|
|
654
|
-
throw new SynomemError('REVISION_CONFLICT', 'The
|
|
923
|
+
throw new SynomemError('REVISION_CONFLICT', 'The task changed before this update was stored.');
|
|
655
924
|
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
656
925
|
const event = {
|
|
657
926
|
...this.eventBase(record.event.id, parsed.expectedVersion + 1),
|
|
658
|
-
type: '
|
|
659
|
-
|
|
927
|
+
type: 'task.updated',
|
|
928
|
+
taskId: record.event.id,
|
|
660
929
|
title: parsed.title ?? record.current.title,
|
|
661
930
|
...(parsed.description !== undefined
|
|
662
931
|
? { description: parsed.description }
|
|
@@ -674,35 +943,40 @@ export class SynomemCore {
|
|
|
674
943
|
await this.repository.insertEvent(event);
|
|
675
944
|
});
|
|
676
945
|
await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
|
|
677
|
-
return await this.
|
|
946
|
+
return await this.getTaskRecord(parsed.taskId);
|
|
678
947
|
}
|
|
679
|
-
async
|
|
680
|
-
const record = await this.
|
|
681
|
-
|
|
682
|
-
|
|
948
|
+
async taskTransition(input, type) {
|
|
949
|
+
const record = await this.getTaskRecord(input.taskId);
|
|
950
|
+
// A rejection must say why. Enforced here as well as in the schema so the
|
|
951
|
+
// failure names the missing thing rather than surfacing as a parse error.
|
|
952
|
+
if (type === 'task.rejected' && !input.response?.trim()) {
|
|
953
|
+
throw new SynomemError('INVALID_INPUT', 'Rejecting a task requires a response explaining why, so the assigner knows whether to reassign it, wait, or change the request.');
|
|
954
|
+
}
|
|
955
|
+
if (type === 'task.accepted' || type === 'task.rejected')
|
|
956
|
+
this.assertTaskAssignee(record);
|
|
683
957
|
else
|
|
684
|
-
this.
|
|
685
|
-
if ((type === '
|
|
686
|
-
if (type === '
|
|
958
|
+
this.assertTaskParticipant(record);
|
|
959
|
+
if ((type === 'task.accepted' || type === 'task.rejected') && record.status !== 'assigned') {
|
|
960
|
+
if (type === 'task.accepted' && record.status === 'open')
|
|
687
961
|
return record;
|
|
688
|
-
if (type === '
|
|
962
|
+
if (type === 'task.rejected' && record.status === 'rejected')
|
|
689
963
|
return record;
|
|
690
|
-
throw new SynomemError('INVALID_INPUT', 'Only assigned
|
|
964
|
+
throw new SynomemError('INVALID_INPUT', 'Only assigned tasks may be accepted or rejected.');
|
|
691
965
|
}
|
|
692
|
-
if (type === '
|
|
966
|
+
if (type === 'task.completed' && record.status !== 'open') {
|
|
693
967
|
if (record.status === 'completed')
|
|
694
968
|
return record;
|
|
695
|
-
throw new SynomemError('INVALID_INPUT', 'Only accepted or self-created open
|
|
969
|
+
throw new SynomemError('INVALID_INPUT', 'Only accepted or self-created open tasks may be completed.');
|
|
696
970
|
}
|
|
697
|
-
if (type === '
|
|
971
|
+
if (type === 'task.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
|
|
698
972
|
if (record.status === 'open')
|
|
699
973
|
return record;
|
|
700
|
-
throw new SynomemError('INVALID_INPUT', 'Only completed or canceled
|
|
974
|
+
throw new SynomemError('INVALID_INPUT', 'Only completed or canceled tasks may be reopened.');
|
|
701
975
|
}
|
|
702
|
-
if (type === '
|
|
976
|
+
if (type === 'task.canceled' && record.status !== 'assigned' && record.status !== 'open') {
|
|
703
977
|
if (record.status === 'canceled')
|
|
704
978
|
return record;
|
|
705
|
-
throw new SynomemError('INVALID_INPUT', 'Only assigned or open
|
|
979
|
+
throw new SynomemError('INVALID_INPUT', 'Only assigned or open tasks may be canceled.');
|
|
706
980
|
}
|
|
707
981
|
await this.repository.transaction(async () => {
|
|
708
982
|
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
@@ -710,33 +984,163 @@ export class SynomemCore {
|
|
|
710
984
|
return;
|
|
711
985
|
const base = {
|
|
712
986
|
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
713
|
-
|
|
987
|
+
taskId: record.event.id,
|
|
714
988
|
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
715
989
|
};
|
|
716
|
-
const event = type === '
|
|
990
|
+
const event = type === 'task.completed'
|
|
717
991
|
? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
|
|
718
|
-
: type === '
|
|
719
|
-
? { ...base, type,
|
|
720
|
-
:
|
|
992
|
+
: type === 'task.rejected'
|
|
993
|
+
? { ...base, type, response: input.response.trim() }
|
|
994
|
+
: type === 'task.accepted'
|
|
995
|
+
? {
|
|
996
|
+
...base,
|
|
997
|
+
type,
|
|
998
|
+
...(input.response ? { response: input.response.trim() } : {}),
|
|
999
|
+
}
|
|
1000
|
+
: type === 'task.canceled'
|
|
1001
|
+
? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
|
|
1002
|
+
: { ...base, type };
|
|
721
1003
|
await this.repository.insertEvent(event);
|
|
722
1004
|
});
|
|
723
1005
|
await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
|
|
724
|
-
return await this.
|
|
1006
|
+
return await this.getTaskRecord(input.taskId);
|
|
1007
|
+
}
|
|
1008
|
+
completeTask(input) {
|
|
1009
|
+
return this.taskTransition(input, 'task.completed');
|
|
1010
|
+
}
|
|
1011
|
+
acceptTask(input) {
|
|
1012
|
+
return this.taskTransition(input, 'task.accepted');
|
|
725
1013
|
}
|
|
726
|
-
|
|
727
|
-
return this.
|
|
1014
|
+
rejectTask(input) {
|
|
1015
|
+
return this.taskTransition(input, 'task.rejected');
|
|
728
1016
|
}
|
|
729
|
-
|
|
730
|
-
return this.
|
|
1017
|
+
reopenTask(input) {
|
|
1018
|
+
return this.taskTransition(input, 'task.reopened');
|
|
731
1019
|
}
|
|
732
|
-
|
|
733
|
-
return this.
|
|
1020
|
+
cancelTask(input) {
|
|
1021
|
+
return this.taskTransition(input, 'task.canceled');
|
|
734
1022
|
}
|
|
735
|
-
|
|
736
|
-
|
|
1023
|
+
/* ----------------------------------------------------------------- todos *
|
|
1024
|
+
* A Todo is a private reminder an agent creates for itself. There is no
|
|
1025
|
+
* assignee, no acceptance, and no visibility choice: it belongs to its author
|
|
1026
|
+
* and only its author reads it. Every method below asserts that ownership
|
|
1027
|
+
* rather than relying on a visibility filter, so a Todo cannot be reached by
|
|
1028
|
+
* guessing its id.
|
|
1029
|
+
*/
|
|
1030
|
+
async createTodo(input) {
|
|
1031
|
+
this.checkAbort();
|
|
1032
|
+
await this.repository.assertEventCompatibility();
|
|
1033
|
+
const parsed = this.validate(() => createTodoSchema.parse(input));
|
|
1034
|
+
const outcome = await this.repository.transaction(async () => {
|
|
1035
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
|
|
1036
|
+
if (prior?.type === 'todo.created')
|
|
1037
|
+
return { id: prior.id, created: false };
|
|
1038
|
+
const id = this.nextId();
|
|
1039
|
+
const event = {
|
|
1040
|
+
...this.eventBase(id, 1, id),
|
|
1041
|
+
type: 'todo.created',
|
|
1042
|
+
title: parsed.title,
|
|
1043
|
+
...(parsed.details !== undefined ? { details: parsed.details } : {}),
|
|
1044
|
+
priority: parsed.priority ?? 3,
|
|
1045
|
+
...(parsed.due ? { due: parsed.due } : {}),
|
|
1046
|
+
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
1047
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
1048
|
+
...(parsed.source ? { source: parsed.source } : {}),
|
|
1049
|
+
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
1050
|
+
};
|
|
1051
|
+
await this.repository.insertEvent(event);
|
|
1052
|
+
return { id, created: true };
|
|
1053
|
+
});
|
|
1054
|
+
return {
|
|
1055
|
+
record: await this.getTodoRecord(outcome.id),
|
|
1056
|
+
created: outcome.created,
|
|
1057
|
+
deduplicated: !outcome.created,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
async getTodoRecord(id) {
|
|
1061
|
+
const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
1062
|
+
if (!record)
|
|
1063
|
+
throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
|
|
1064
|
+
this.assertTodoOwner(record);
|
|
1065
|
+
return record;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Todos are owner-only. No role, however broad, reads another actor's
|
|
1069
|
+
* private reminders — that is a named administrative capability this release
|
|
1070
|
+
* does not have, not something a permission check quietly allows.
|
|
1071
|
+
*/
|
|
1072
|
+
assertTodoOwner(record) {
|
|
1073
|
+
const owner = record.event.actor;
|
|
1074
|
+
if (owner.kind !== this.actor.kind || owner.id !== this.actor.id) {
|
|
1075
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'A todo is private to the actor who created it.');
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
async getTodo(id) {
|
|
1079
|
+
this.checkAbort();
|
|
1080
|
+
return await this.getTodoRecord(id);
|
|
1081
|
+
}
|
|
1082
|
+
async updateTodo(input) {
|
|
1083
|
+
this.checkAbort();
|
|
1084
|
+
const parsed = this.validate(() => updateTodoSchema.parse(input));
|
|
1085
|
+
const record = await this.getTodoRecord(parsed.todoId);
|
|
1086
|
+
if (record.current.version !== parsed.expectedVersion) {
|
|
1087
|
+
throw new SynomemError('REVISION_CONFLICT', `Todo ${parsed.todoId} is at version ${record.current.version}.`);
|
|
1088
|
+
}
|
|
1089
|
+
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
1090
|
+
await this.repository.transaction(async () => {
|
|
1091
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
|
|
1092
|
+
if (prior)
|
|
1093
|
+
return;
|
|
1094
|
+
const event = {
|
|
1095
|
+
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
1096
|
+
type: 'todo.updated',
|
|
1097
|
+
todoId: record.event.id,
|
|
1098
|
+
title: parsed.title ?? record.current.title,
|
|
1099
|
+
...((parsed.details ?? record.current.details)
|
|
1100
|
+
? { details: parsed.details ?? record.current.details }
|
|
1101
|
+
: {}),
|
|
1102
|
+
priority: parsed.priority ?? record.current.priority,
|
|
1103
|
+
...(due ? { due } : {}),
|
|
1104
|
+
tags: [...new Set(parsed.tags ?? record.current.tags)].sort(),
|
|
1105
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
1106
|
+
};
|
|
1107
|
+
await this.repository.insertEvent(event);
|
|
1108
|
+
});
|
|
1109
|
+
return await this.getTodoRecord(parsed.todoId);
|
|
737
1110
|
}
|
|
738
|
-
|
|
739
|
-
|
|
1111
|
+
async todoTransition(input, type) {
|
|
1112
|
+
const record = await this.getTodoRecord(input.todoId);
|
|
1113
|
+
if (type === 'todo.completed' && record.status !== 'open') {
|
|
1114
|
+
if (record.status === 'completed')
|
|
1115
|
+
return record;
|
|
1116
|
+
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
|
|
1117
|
+
}
|
|
1118
|
+
if (type === 'todo.reopened' && record.status === 'open')
|
|
1119
|
+
return record;
|
|
1120
|
+
if (type === 'todo.canceled' && record.status !== 'open') {
|
|
1121
|
+
if (record.status === 'canceled')
|
|
1122
|
+
return record;
|
|
1123
|
+
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
|
|
1124
|
+
}
|
|
1125
|
+
if (type === 'todo.archived' && record.status === 'archived')
|
|
1126
|
+
return record;
|
|
1127
|
+
await this.repository.transaction(async () => {
|
|
1128
|
+
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
1129
|
+
if (prior)
|
|
1130
|
+
return;
|
|
1131
|
+
const base = {
|
|
1132
|
+
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
1133
|
+
todoId: record.event.id,
|
|
1134
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
1135
|
+
};
|
|
1136
|
+
const event = type === 'todo.completed'
|
|
1137
|
+
? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
|
|
1138
|
+
: type === 'todo.canceled'
|
|
1139
|
+
? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
|
|
1140
|
+
: { ...base, type };
|
|
1141
|
+
await this.repository.insertEvent(event);
|
|
1142
|
+
});
|
|
1143
|
+
return await this.getTodoRecord(input.todoId);
|
|
740
1144
|
}
|
|
741
1145
|
async listItems(input) {
|
|
742
1146
|
this.checkAbort();
|
|
@@ -765,7 +1169,7 @@ export class SynomemCore {
|
|
|
765
1169
|
return this.getMemo(id);
|
|
766
1170
|
if (summary.kind === 'note')
|
|
767
1171
|
return this.getNote(id);
|
|
768
|
-
return this.
|
|
1172
|
+
return this.getTask(id);
|
|
769
1173
|
}
|
|
770
1174
|
async stats(input = {}) {
|
|
771
1175
|
this.checkAbort();
|
|
@@ -818,6 +1222,14 @@ export class SynomemCore {
|
|
|
818
1222
|
return stats;
|
|
819
1223
|
}
|
|
820
1224
|
}
|
|
1225
|
+
/**
|
|
1226
|
+
* The schema version this package writes and expects. Named rather than
|
|
1227
|
+
* repeated as a literal because it is asserted in three places, and a doctor
|
|
1228
|
+
* check that silently lags the migration runner reports a healthy database as
|
|
1229
|
+
* broken.
|
|
1230
|
+
*/
|
|
1231
|
+
const CURRENT_SCHEMA_VERSION = 6;
|
|
1232
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6];
|
|
821
1233
|
export class SynomemClient extends SynomemCore {
|
|
822
1234
|
home;
|
|
823
1235
|
storage;
|
|
@@ -915,8 +1327,9 @@ export class SynomemClient extends SynomemCore {
|
|
|
915
1327
|
message: `${itemHealth.indexed} of ${itemHealth.created} item aggregates are present in the shared current-state index.`,
|
|
916
1328
|
});
|
|
917
1329
|
const migrationState = this.storage.migrationState();
|
|
918
|
-
const migrationsValid = migrationState.schemaVersion ===
|
|
919
|
-
JSON.stringify(migrationState.appliedVersions) ===
|
|
1330
|
+
const migrationsValid = migrationState.schemaVersion === CURRENT_SCHEMA_VERSION &&
|
|
1331
|
+
JSON.stringify(migrationState.appliedVersions) ===
|
|
1332
|
+
JSON.stringify(EXPECTED_APPLIED_MIGRATIONS);
|
|
920
1333
|
diagnostics.push({
|
|
921
1334
|
level: migrationsValid ? 'ok' : 'error',
|
|
922
1335
|
code: migrationsValid ? 'MIGRATIONS_VALID' : 'MIGRATIONS_INCONSISTENT',
|
|
@@ -987,7 +1400,7 @@ export class SynomemClient extends SynomemCore {
|
|
|
987
1400
|
const records = recordsFromEvents(events);
|
|
988
1401
|
const memos = memoRecordsFromEvents(events);
|
|
989
1402
|
const notes = noteRecordsFromEvents(events);
|
|
990
|
-
const
|
|
1403
|
+
const tasks = taskRecordsFromEvents(events);
|
|
991
1404
|
const warning = scan.invalid.length
|
|
992
1405
|
? `> Warning: ${scan.invalid.length} unsupported or malformed event(s) omitted from this Markdown view: ${scan.invalid.map((item) => item.id).join(', ')}\n\n`
|
|
993
1406
|
: '';
|
|
@@ -995,7 +1408,7 @@ export class SynomemClient extends SynomemCore {
|
|
|
995
1408
|
...records.map((record) => `## Kudos: ${escapeMarkdown(record.event.title)}\n\n${escapeMarkdown(record.event.reason)}\n\nStatus: ${record.revocationStatus === 'revoked' ? 'Revoked' : record.status}\n\nID: \`${record.event.id}\``),
|
|
996
1409
|
...memos.map((record) => `## Memo: ${escapeMarkdown(record.event.subject)}\n\n${escapeMarkdown(record.event.body)}\n\nStatus: ${record.status}\n\nID: \`${record.event.id}\``),
|
|
997
1410
|
...notes.map((record) => `## Note: ${escapeMarkdown(record.current.title)}\n\n${escapeMarkdown(record.current.body)}\n\nStatus: ${record.status}; version ${record.current.version}\n\nID: \`${record.event.id}\``),
|
|
998
|
-
...
|
|
1411
|
+
...tasks.map((record) => `## 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}\``),
|
|
999
1412
|
];
|
|
1000
1413
|
return `${warning}${sections.join('\n\n')}\n`;
|
|
1001
1414
|
}
|