synomem 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +37 -1
- package/README.md +28 -15
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +311 -70
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +96 -17
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +333 -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 +207 -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 +156 -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 +18 -1
- package/dist/ports/repository.d.ts.map +1 -1
- package/dist/projections.d.ts +18 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +137 -44
- package/dist/projections.js.map +1 -1
- package/dist/remote.d.ts +57 -9
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +43 -2
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +302 -21
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +142 -26
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +57 -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 +41 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +254 -36
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +213 -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 +572 -173
- package/src/client.ts +391 -79
- package/src/config.ts +8 -8
- package/src/index.ts +11 -1
- package/src/mcp/index.ts +189 -30
- package/src/mcp-server.ts +58 -8
- package/src/ports/repository.ts +16 -0
- package/src/projections.ts +139 -44
- package/src/remote.ts +118 -8
- package/src/schemas.ts +147 -26
- package/src/service.ts +59 -7
- package/src/skill-install.ts +14 -17
- package/src/storage.ts +326 -34
- package/src/types.ts +228 -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, ProjectionManager, recordsFromEvents, taskRecordsFromEvents, todoRecordsFromEvents, } from './projections.js';
|
|
8
|
+
import { actorSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, 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),
|
|
@@ -44,16 +49,59 @@ export class SynomemCore {
|
|
|
44
49
|
revise: (input) => this.reviseNote(input),
|
|
45
50
|
archive: (input) => this.archiveNote(input),
|
|
46
51
|
};
|
|
52
|
+
tasks = {
|
|
53
|
+
create: (input) => this.createTask(input),
|
|
54
|
+
list: (input = {}) => this.listItems({ ...input, kinds: ['task'] }),
|
|
55
|
+
get: (id) => this.getTask(id),
|
|
56
|
+
update: (input) => this.updateTask(input),
|
|
57
|
+
// A response is optional when accepting and required when rejecting: a
|
|
58
|
+
// refusal without a reason leaves the assigner unable to act on it.
|
|
59
|
+
accept: (input) => this.acceptTask(input),
|
|
60
|
+
reject: (input) => this.rejectTask(input),
|
|
61
|
+
complete: (input) => this.completeTask(input),
|
|
62
|
+
reopen: (input) => this.reopenTask(input),
|
|
63
|
+
cancel: (input) => this.cancelTask(input),
|
|
64
|
+
};
|
|
47
65
|
todos = {
|
|
48
66
|
create: (input) => this.createTodo(input),
|
|
49
67
|
list: (input = {}) => this.listItems({ ...input, kinds: ['todo'] }),
|
|
50
68
|
get: (id) => this.getTodo(id),
|
|
51
69
|
update: (input) => this.updateTodo(input),
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
complete: (input) => this.todoTransition(input, 'todo.completed'),
|
|
71
|
+
reopen: (input) => this.todoTransition(input, 'todo.reopened'),
|
|
72
|
+
cancel: (input) => this.todoTransition(input, 'todo.canceled'),
|
|
73
|
+
archive: (input) => this.todoTransition(input, 'todo.archived'),
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Unanswered and overdue discovery.
|
|
77
|
+
*
|
|
78
|
+
* The plan asks for shared work to be observable without treating runtime
|
|
79
|
+
* metadata as a delivery guarantee. These are query states derived from
|
|
80
|
+
* durable events: they say nobody has answered yet, not that the agent was
|
|
81
|
+
* offline, missed a notification, or lacks a capability it claimed.
|
|
82
|
+
*/
|
|
83
|
+
discovery = {
|
|
84
|
+
/**
|
|
85
|
+
* Tasks awaiting acceptance, unread memos, and unacknowledged kudos —
|
|
86
|
+
* optionally only those older than a given age or instant.
|
|
87
|
+
*/
|
|
88
|
+
unanswered: (input = {}) => {
|
|
89
|
+
const { olderThanHours, awaitingSince, ...rest } = input;
|
|
90
|
+
const since = awaitingSince ??
|
|
91
|
+
(olderThanHours !== undefined
|
|
92
|
+
? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
|
|
93
|
+
: undefined);
|
|
94
|
+
return this.listItems({
|
|
95
|
+
...rest,
|
|
96
|
+
awaitingResponse: true,
|
|
97
|
+
...(since ? { awaitingSince: since } : {}),
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
/** Open work whose deadline has passed. Defaults to "now". */
|
|
101
|
+
overdue: (input = {}) => {
|
|
102
|
+
const { asOf, ...rest } = input;
|
|
103
|
+
return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
|
|
104
|
+
},
|
|
57
105
|
};
|
|
58
106
|
items = {
|
|
59
107
|
list: (input = {}) => this.listItems(input),
|
|
@@ -152,7 +200,7 @@ export class SynomemCore {
|
|
|
152
200
|
async updateAgent(idOrAlias, changes) {
|
|
153
201
|
this.checkAbort();
|
|
154
202
|
await this.repository.assertEventCompatibility();
|
|
155
|
-
this.validate(() =>
|
|
203
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
156
204
|
const parsed = this.validate(() => updateAgentSchema.parse(changes));
|
|
157
205
|
const existing = await this.repository.getAgent(idOrAlias);
|
|
158
206
|
if (!existing)
|
|
@@ -188,7 +236,7 @@ export class SynomemCore {
|
|
|
188
236
|
}
|
|
189
237
|
async getAgent(idOrAlias) {
|
|
190
238
|
this.checkAbort();
|
|
191
|
-
this.validate(() =>
|
|
239
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
192
240
|
const profile = await this.repository.getAgent(idOrAlias);
|
|
193
241
|
if (!profile)
|
|
194
242
|
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
@@ -198,6 +246,71 @@ export class SynomemCore {
|
|
|
198
246
|
this.checkAbort();
|
|
199
247
|
return await this.repository.listAgents();
|
|
200
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Resolves a name without ever choosing between equally valid answers.
|
|
251
|
+
*
|
|
252
|
+
* Callers that want a single agent should treat an empty `match` as a
|
|
253
|
+
* question for the user, not as "not found": `candidates` distinguishes the
|
|
254
|
+
* two cases.
|
|
255
|
+
*/
|
|
256
|
+
async resolveAgent(query) {
|
|
257
|
+
this.checkAbort();
|
|
258
|
+
const trimmed = query.trim();
|
|
259
|
+
if (!trimmed)
|
|
260
|
+
throw new SynomemError('INVALID_INPUT', 'A lookup name is required.');
|
|
261
|
+
const resolved = await this.repository.resolveAgent(trimmed);
|
|
262
|
+
return {
|
|
263
|
+
query: trimmed,
|
|
264
|
+
...(resolved.match ? { match: resolved.match } : {}),
|
|
265
|
+
candidates: resolved.candidates,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async agentDirectory() {
|
|
269
|
+
this.checkAbort();
|
|
270
|
+
const profiles = await this.repository.listAgents();
|
|
271
|
+
const entries = [];
|
|
272
|
+
for (const profile of profiles) {
|
|
273
|
+
entries.push({
|
|
274
|
+
profile,
|
|
275
|
+
runtimeBindings: await this.repository.listRuntimeBindings(profile.id),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return entries;
|
|
279
|
+
}
|
|
280
|
+
async listRuntimeBindings(idOrAlias) {
|
|
281
|
+
const profile = await this.getAgent(idOrAlias);
|
|
282
|
+
return await this.repository.listRuntimeBindings(profile.id);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Records where an agent runs. Re-binding the same runtime and profile
|
|
286
|
+
* updates the claim in place rather than accumulating duplicates, because a
|
|
287
|
+
* reinstall is the same agent in the same place, not a second one.
|
|
288
|
+
*/
|
|
289
|
+
async bindRuntime(input) {
|
|
290
|
+
this.checkAbort();
|
|
291
|
+
const parsed = this.validate(() => bindRuntimeSchema.parse(input));
|
|
292
|
+
const profile = await this.getAgent(parsed.agentId);
|
|
293
|
+
await this.repository.bindRuntime({
|
|
294
|
+
id: this.idGenerator(),
|
|
295
|
+
agentId: profile.id,
|
|
296
|
+
...(parsed.installationId !== undefined ? { installationId: parsed.installationId } : {}),
|
|
297
|
+
runtime: parsed.runtime,
|
|
298
|
+
...(parsed.profile !== undefined ? { profile: parsed.profile } : {}),
|
|
299
|
+
...(parsed.capabilities !== undefined ? { capabilities: parsed.capabilities } : {}),
|
|
300
|
+
boundAt: this.now(),
|
|
301
|
+
});
|
|
302
|
+
const bindings = await this.repository.listRuntimeBindings(profile.id);
|
|
303
|
+
const binding = bindings.find((candidate) => candidate.runtime === parsed.runtime &&
|
|
304
|
+
(candidate.profile ?? '') === (parsed.profile ?? '') &&
|
|
305
|
+
(candidate.installationId ?? '') === (parsed.installationId ?? ''));
|
|
306
|
+
if (!binding)
|
|
307
|
+
throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
|
|
308
|
+
return binding;
|
|
309
|
+
}
|
|
310
|
+
async unbindRuntime(bindingId) {
|
|
311
|
+
this.checkAbort();
|
|
312
|
+
return await this.repository.unbindRuntime(bindingId);
|
|
313
|
+
}
|
|
201
314
|
async giveKudos(input) {
|
|
202
315
|
this.checkAbort();
|
|
203
316
|
await this.repository.assertEventCompatibility();
|
|
@@ -351,7 +464,7 @@ export class SynomemCore {
|
|
|
351
464
|
? 'MEMO_NOT_FOUND'
|
|
352
465
|
: kind === 'note'
|
|
353
466
|
? 'NOTE_NOT_FOUND'
|
|
354
|
-
: kind === '
|
|
467
|
+
: kind === 'task'
|
|
355
468
|
? 'TODO_NOT_FOUND'
|
|
356
469
|
: 'KUDOS_NOT_FOUND';
|
|
357
470
|
throw new SynomemError(code, `Unknown ${kind}: ${id}`);
|
|
@@ -567,40 +680,40 @@ export class SynomemCore {
|
|
|
567
680
|
await this.projectionWriter.syncAgent(record.event.ownerAgentId);
|
|
568
681
|
return await this.getNoteRecord(input.noteId);
|
|
569
682
|
}
|
|
570
|
-
async
|
|
571
|
-
await this.requireVisibleItem(id, '
|
|
572
|
-
const record =
|
|
683
|
+
async getTaskRecord(id) {
|
|
684
|
+
await this.requireVisibleItem(id, 'task');
|
|
685
|
+
const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
573
686
|
if (!record)
|
|
574
|
-
throw new SynomemError('TODO_NOT_FOUND', `Unknown
|
|
687
|
+
throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
|
|
575
688
|
return record;
|
|
576
689
|
}
|
|
577
|
-
async
|
|
690
|
+
async getTask(id) {
|
|
578
691
|
this.checkAbort();
|
|
579
|
-
return await this.
|
|
692
|
+
return await this.getTaskRecord(id);
|
|
580
693
|
}
|
|
581
|
-
async
|
|
694
|
+
async createTask(input) {
|
|
582
695
|
this.checkAbort();
|
|
583
696
|
await this.repository.assertEventCompatibility();
|
|
584
|
-
const parsed = this.validate(() =>
|
|
697
|
+
const parsed = this.validate(() => createTaskSchema.parse(input));
|
|
585
698
|
const assigneeId = parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
|
|
586
699
|
if (!assigneeId)
|
|
587
700
|
throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify assigneeAgentId.');
|
|
588
701
|
const assignee = await this.repository.getAgent(assigneeId);
|
|
589
702
|
if (!assignee)
|
|
590
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown
|
|
703
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
|
|
591
704
|
if (this.actor.kind === 'agent' &&
|
|
592
705
|
this.actor.id !== assignee.id &&
|
|
593
|
-
!this.repository.config.
|
|
594
|
-
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent
|
|
706
|
+
!this.repository.config.allowCrossAgentTasks)
|
|
707
|
+
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
|
|
595
708
|
const outcome = await this.repository.transaction(async () => {
|
|
596
|
-
const prior = await this.priorMutation(parsed.idempotencyKey, '
|
|
597
|
-
if (prior?.type === '
|
|
709
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
|
|
710
|
+
if (prior?.type === 'task.created')
|
|
598
711
|
return { id: prior.id, created: false };
|
|
599
712
|
const id = this.nextId();
|
|
600
713
|
const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
|
|
601
714
|
const event = {
|
|
602
715
|
...this.eventBase(id, 1, id),
|
|
603
|
-
type: '
|
|
716
|
+
type: 'task.created',
|
|
604
717
|
assigneeAgentId: assignee.id,
|
|
605
718
|
assigneeDisplayName: assignee.displayName,
|
|
606
719
|
title: parsed.title,
|
|
@@ -620,43 +733,43 @@ export class SynomemCore {
|
|
|
620
733
|
if (outcome.created)
|
|
621
734
|
await this.projectionWriter.syncAgent(assignee.id);
|
|
622
735
|
return {
|
|
623
|
-
record: await this.
|
|
736
|
+
record: await this.getTaskRecord(outcome.id),
|
|
624
737
|
created: outcome.created,
|
|
625
738
|
deduplicated: !outcome.created,
|
|
626
739
|
};
|
|
627
740
|
}
|
|
628
|
-
|
|
741
|
+
assertTaskParticipant(record) {
|
|
629
742
|
const isCreator = record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
|
|
630
743
|
const isAssignee = this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId;
|
|
631
744
|
if (!this.administrative && !isCreator && !isAssignee)
|
|
632
|
-
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the
|
|
745
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the task creator, assignee, or a human administrator may change it.');
|
|
633
746
|
}
|
|
634
|
-
|
|
747
|
+
assertTaskAssignee(record) {
|
|
635
748
|
if (!this.administrative &&
|
|
636
749
|
!(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
|
|
750
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assigned agent or a human administrator may accept or reject this task.');
|
|
638
751
|
}
|
|
639
752
|
}
|
|
640
|
-
async
|
|
641
|
-
const parsed = this.validate(() =>
|
|
642
|
-
const record = await this.
|
|
643
|
-
this.
|
|
753
|
+
async updateTask(input) {
|
|
754
|
+
const parsed = this.validate(() => updateTaskSchema.parse(input));
|
|
755
|
+
const record = await this.getTaskRecord(parsed.taskId);
|
|
756
|
+
this.assertTaskParticipant(record);
|
|
644
757
|
if (record.status !== 'open')
|
|
645
|
-
throw new SynomemError('INVALID_INPUT', 'Only open
|
|
758
|
+
throw new SynomemError('INVALID_INPUT', 'Only open tasks can be updated.');
|
|
646
759
|
if (parsed.expectedVersion !== record.current.version)
|
|
647
|
-
throw new SynomemError('REVISION_CONFLICT', `Expected
|
|
760
|
+
throw new SynomemError('REVISION_CONFLICT', `Expected task version ${parsed.expectedVersion}; current version is ${record.current.version}.`);
|
|
648
761
|
await this.repository.transaction(async () => {
|
|
649
|
-
const prior = await this.priorMutation(parsed.idempotencyKey, '
|
|
762
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.updated');
|
|
650
763
|
if (prior)
|
|
651
764
|
return;
|
|
652
765
|
if ((await this.repository.nextAggregateVersion(record.event.id)) !==
|
|
653
766
|
parsed.expectedVersion + 1)
|
|
654
|
-
throw new SynomemError('REVISION_CONFLICT', 'The
|
|
767
|
+
throw new SynomemError('REVISION_CONFLICT', 'The task changed before this update was stored.');
|
|
655
768
|
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
656
769
|
const event = {
|
|
657
770
|
...this.eventBase(record.event.id, parsed.expectedVersion + 1),
|
|
658
|
-
type: '
|
|
659
|
-
|
|
771
|
+
type: 'task.updated',
|
|
772
|
+
taskId: record.event.id,
|
|
660
773
|
title: parsed.title ?? record.current.title,
|
|
661
774
|
...(parsed.description !== undefined
|
|
662
775
|
? { description: parsed.description }
|
|
@@ -674,35 +787,40 @@ export class SynomemCore {
|
|
|
674
787
|
await this.repository.insertEvent(event);
|
|
675
788
|
});
|
|
676
789
|
await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
|
|
677
|
-
return await this.
|
|
790
|
+
return await this.getTaskRecord(parsed.taskId);
|
|
678
791
|
}
|
|
679
|
-
async
|
|
680
|
-
const record = await this.
|
|
681
|
-
|
|
682
|
-
|
|
792
|
+
async taskTransition(input, type) {
|
|
793
|
+
const record = await this.getTaskRecord(input.taskId);
|
|
794
|
+
// A rejection must say why. Enforced here as well as in the schema so the
|
|
795
|
+
// failure names the missing thing rather than surfacing as a parse error.
|
|
796
|
+
if (type === 'task.rejected' && !input.response?.trim()) {
|
|
797
|
+
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.');
|
|
798
|
+
}
|
|
799
|
+
if (type === 'task.accepted' || type === 'task.rejected')
|
|
800
|
+
this.assertTaskAssignee(record);
|
|
683
801
|
else
|
|
684
|
-
this.
|
|
685
|
-
if ((type === '
|
|
686
|
-
if (type === '
|
|
802
|
+
this.assertTaskParticipant(record);
|
|
803
|
+
if ((type === 'task.accepted' || type === 'task.rejected') && record.status !== 'assigned') {
|
|
804
|
+
if (type === 'task.accepted' && record.status === 'open')
|
|
687
805
|
return record;
|
|
688
|
-
if (type === '
|
|
806
|
+
if (type === 'task.rejected' && record.status === 'rejected')
|
|
689
807
|
return record;
|
|
690
|
-
throw new SynomemError('INVALID_INPUT', 'Only assigned
|
|
808
|
+
throw new SynomemError('INVALID_INPUT', 'Only assigned tasks may be accepted or rejected.');
|
|
691
809
|
}
|
|
692
|
-
if (type === '
|
|
810
|
+
if (type === 'task.completed' && record.status !== 'open') {
|
|
693
811
|
if (record.status === 'completed')
|
|
694
812
|
return record;
|
|
695
|
-
throw new SynomemError('INVALID_INPUT', 'Only accepted or self-created open
|
|
813
|
+
throw new SynomemError('INVALID_INPUT', 'Only accepted or self-created open tasks may be completed.');
|
|
696
814
|
}
|
|
697
|
-
if (type === '
|
|
815
|
+
if (type === 'task.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
|
|
698
816
|
if (record.status === 'open')
|
|
699
817
|
return record;
|
|
700
|
-
throw new SynomemError('INVALID_INPUT', 'Only completed or canceled
|
|
818
|
+
throw new SynomemError('INVALID_INPUT', 'Only completed or canceled tasks may be reopened.');
|
|
701
819
|
}
|
|
702
|
-
if (type === '
|
|
820
|
+
if (type === 'task.canceled' && record.status !== 'assigned' && record.status !== 'open') {
|
|
703
821
|
if (record.status === 'canceled')
|
|
704
822
|
return record;
|
|
705
|
-
throw new SynomemError('INVALID_INPUT', 'Only assigned or open
|
|
823
|
+
throw new SynomemError('INVALID_INPUT', 'Only assigned or open tasks may be canceled.');
|
|
706
824
|
}
|
|
707
825
|
await this.repository.transaction(async () => {
|
|
708
826
|
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
@@ -710,33 +828,163 @@ export class SynomemCore {
|
|
|
710
828
|
return;
|
|
711
829
|
const base = {
|
|
712
830
|
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
713
|
-
|
|
831
|
+
taskId: record.event.id,
|
|
714
832
|
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
715
833
|
};
|
|
716
|
-
const event = type === '
|
|
834
|
+
const event = type === 'task.completed'
|
|
717
835
|
? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
|
|
718
|
-
: type === '
|
|
719
|
-
? { ...base, type,
|
|
720
|
-
:
|
|
836
|
+
: type === 'task.rejected'
|
|
837
|
+
? { ...base, type, response: input.response.trim() }
|
|
838
|
+
: type === 'task.accepted'
|
|
839
|
+
? {
|
|
840
|
+
...base,
|
|
841
|
+
type,
|
|
842
|
+
...(input.response ? { response: input.response.trim() } : {}),
|
|
843
|
+
}
|
|
844
|
+
: type === 'task.canceled'
|
|
845
|
+
? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
|
|
846
|
+
: { ...base, type };
|
|
721
847
|
await this.repository.insertEvent(event);
|
|
722
848
|
});
|
|
723
849
|
await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
|
|
724
|
-
return await this.
|
|
850
|
+
return await this.getTaskRecord(input.taskId);
|
|
725
851
|
}
|
|
726
|
-
|
|
727
|
-
return this.
|
|
852
|
+
completeTask(input) {
|
|
853
|
+
return this.taskTransition(input, 'task.completed');
|
|
728
854
|
}
|
|
729
|
-
|
|
730
|
-
return this.
|
|
855
|
+
acceptTask(input) {
|
|
856
|
+
return this.taskTransition(input, 'task.accepted');
|
|
731
857
|
}
|
|
732
|
-
|
|
733
|
-
return this.
|
|
858
|
+
rejectTask(input) {
|
|
859
|
+
return this.taskTransition(input, 'task.rejected');
|
|
734
860
|
}
|
|
735
|
-
|
|
736
|
-
return this.
|
|
861
|
+
reopenTask(input) {
|
|
862
|
+
return this.taskTransition(input, 'task.reopened');
|
|
737
863
|
}
|
|
738
|
-
|
|
739
|
-
return this.
|
|
864
|
+
cancelTask(input) {
|
|
865
|
+
return this.taskTransition(input, 'task.canceled');
|
|
866
|
+
}
|
|
867
|
+
/* ----------------------------------------------------------------- todos *
|
|
868
|
+
* A Todo is a private reminder an agent creates for itself. There is no
|
|
869
|
+
* assignee, no acceptance, and no visibility choice: it belongs to its author
|
|
870
|
+
* and only its author reads it. Every method below asserts that ownership
|
|
871
|
+
* rather than relying on a visibility filter, so a Todo cannot be reached by
|
|
872
|
+
* guessing its id.
|
|
873
|
+
*/
|
|
874
|
+
async createTodo(input) {
|
|
875
|
+
this.checkAbort();
|
|
876
|
+
await this.repository.assertEventCompatibility();
|
|
877
|
+
const parsed = this.validate(() => createTodoSchema.parse(input));
|
|
878
|
+
const outcome = await this.repository.transaction(async () => {
|
|
879
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
|
|
880
|
+
if (prior?.type === 'todo.created')
|
|
881
|
+
return { id: prior.id, created: false };
|
|
882
|
+
const id = this.nextId();
|
|
883
|
+
const event = {
|
|
884
|
+
...this.eventBase(id, 1, id),
|
|
885
|
+
type: 'todo.created',
|
|
886
|
+
title: parsed.title,
|
|
887
|
+
...(parsed.details !== undefined ? { details: parsed.details } : {}),
|
|
888
|
+
priority: parsed.priority ?? 3,
|
|
889
|
+
...(parsed.due ? { due: parsed.due } : {}),
|
|
890
|
+
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
891
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
892
|
+
...(parsed.source ? { source: parsed.source } : {}),
|
|
893
|
+
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
894
|
+
};
|
|
895
|
+
await this.repository.insertEvent(event);
|
|
896
|
+
return { id, created: true };
|
|
897
|
+
});
|
|
898
|
+
return {
|
|
899
|
+
record: await this.getTodoRecord(outcome.id),
|
|
900
|
+
created: outcome.created,
|
|
901
|
+
deduplicated: !outcome.created,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
async getTodoRecord(id) {
|
|
905
|
+
const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
906
|
+
if (!record)
|
|
907
|
+
throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
|
|
908
|
+
this.assertTodoOwner(record);
|
|
909
|
+
return record;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Todos are owner-only. No role, however broad, reads another actor's
|
|
913
|
+
* private reminders — that is a named administrative capability this release
|
|
914
|
+
* does not have, not something a permission check quietly allows.
|
|
915
|
+
*/
|
|
916
|
+
assertTodoOwner(record) {
|
|
917
|
+
const owner = record.event.actor;
|
|
918
|
+
if (owner.kind !== this.actor.kind || owner.id !== this.actor.id) {
|
|
919
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'A todo is private to the actor who created it.');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async getTodo(id) {
|
|
923
|
+
this.checkAbort();
|
|
924
|
+
return await this.getTodoRecord(id);
|
|
925
|
+
}
|
|
926
|
+
async updateTodo(input) {
|
|
927
|
+
this.checkAbort();
|
|
928
|
+
const parsed = this.validate(() => updateTodoSchema.parse(input));
|
|
929
|
+
const record = await this.getTodoRecord(parsed.todoId);
|
|
930
|
+
if (record.current.version !== parsed.expectedVersion) {
|
|
931
|
+
throw new SynomemError('REVISION_CONFLICT', `Todo ${parsed.todoId} is at version ${record.current.version}.`);
|
|
932
|
+
}
|
|
933
|
+
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
934
|
+
await this.repository.transaction(async () => {
|
|
935
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
|
|
936
|
+
if (prior)
|
|
937
|
+
return;
|
|
938
|
+
const event = {
|
|
939
|
+
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
940
|
+
type: 'todo.updated',
|
|
941
|
+
todoId: record.event.id,
|
|
942
|
+
title: parsed.title ?? record.current.title,
|
|
943
|
+
...((parsed.details ?? record.current.details)
|
|
944
|
+
? { details: parsed.details ?? record.current.details }
|
|
945
|
+
: {}),
|
|
946
|
+
priority: parsed.priority ?? record.current.priority,
|
|
947
|
+
...(due ? { due } : {}),
|
|
948
|
+
tags: [...new Set(parsed.tags ?? record.current.tags)].sort(),
|
|
949
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
950
|
+
};
|
|
951
|
+
await this.repository.insertEvent(event);
|
|
952
|
+
});
|
|
953
|
+
return await this.getTodoRecord(parsed.todoId);
|
|
954
|
+
}
|
|
955
|
+
async todoTransition(input, type) {
|
|
956
|
+
const record = await this.getTodoRecord(input.todoId);
|
|
957
|
+
if (type === 'todo.completed' && record.status !== 'open') {
|
|
958
|
+
if (record.status === 'completed')
|
|
959
|
+
return record;
|
|
960
|
+
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
|
|
961
|
+
}
|
|
962
|
+
if (type === 'todo.reopened' && record.status === 'open')
|
|
963
|
+
return record;
|
|
964
|
+
if (type === 'todo.canceled' && record.status !== 'open') {
|
|
965
|
+
if (record.status === 'canceled')
|
|
966
|
+
return record;
|
|
967
|
+
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
|
|
968
|
+
}
|
|
969
|
+
if (type === 'todo.archived' && record.status === 'archived')
|
|
970
|
+
return record;
|
|
971
|
+
await this.repository.transaction(async () => {
|
|
972
|
+
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
973
|
+
if (prior)
|
|
974
|
+
return;
|
|
975
|
+
const base = {
|
|
976
|
+
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
977
|
+
todoId: record.event.id,
|
|
978
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
979
|
+
};
|
|
980
|
+
const event = type === 'todo.completed'
|
|
981
|
+
? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
|
|
982
|
+
: type === 'todo.canceled'
|
|
983
|
+
? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
|
|
984
|
+
: { ...base, type };
|
|
985
|
+
await this.repository.insertEvent(event);
|
|
986
|
+
});
|
|
987
|
+
return await this.getTodoRecord(input.todoId);
|
|
740
988
|
}
|
|
741
989
|
async listItems(input) {
|
|
742
990
|
this.checkAbort();
|
|
@@ -765,7 +1013,7 @@ export class SynomemCore {
|
|
|
765
1013
|
return this.getMemo(id);
|
|
766
1014
|
if (summary.kind === 'note')
|
|
767
1015
|
return this.getNote(id);
|
|
768
|
-
return this.
|
|
1016
|
+
return this.getTask(id);
|
|
769
1017
|
}
|
|
770
1018
|
async stats(input = {}) {
|
|
771
1019
|
this.checkAbort();
|
|
@@ -818,6 +1066,14 @@ export class SynomemCore {
|
|
|
818
1066
|
return stats;
|
|
819
1067
|
}
|
|
820
1068
|
}
|
|
1069
|
+
/**
|
|
1070
|
+
* The schema version this package writes and expects. Named rather than
|
|
1071
|
+
* repeated as a literal because it is asserted in three places, and a doctor
|
|
1072
|
+
* check that silently lags the migration runner reports a healthy database as
|
|
1073
|
+
* broken.
|
|
1074
|
+
*/
|
|
1075
|
+
const CURRENT_SCHEMA_VERSION = 5;
|
|
1076
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5];
|
|
821
1077
|
export class SynomemClient extends SynomemCore {
|
|
822
1078
|
home;
|
|
823
1079
|
storage;
|
|
@@ -915,8 +1171,9 @@ export class SynomemClient extends SynomemCore {
|
|
|
915
1171
|
message: `${itemHealth.indexed} of ${itemHealth.created} item aggregates are present in the shared current-state index.`,
|
|
916
1172
|
});
|
|
917
1173
|
const migrationState = this.storage.migrationState();
|
|
918
|
-
const migrationsValid = migrationState.schemaVersion ===
|
|
919
|
-
JSON.stringify(migrationState.appliedVersions) ===
|
|
1174
|
+
const migrationsValid = migrationState.schemaVersion === CURRENT_SCHEMA_VERSION &&
|
|
1175
|
+
JSON.stringify(migrationState.appliedVersions) ===
|
|
1176
|
+
JSON.stringify(EXPECTED_APPLIED_MIGRATIONS);
|
|
920
1177
|
diagnostics.push({
|
|
921
1178
|
level: migrationsValid ? 'ok' : 'error',
|
|
922
1179
|
code: migrationsValid ? 'MIGRATIONS_VALID' : 'MIGRATIONS_INCONSISTENT',
|
|
@@ -987,7 +1244,7 @@ export class SynomemClient extends SynomemCore {
|
|
|
987
1244
|
const records = recordsFromEvents(events);
|
|
988
1245
|
const memos = memoRecordsFromEvents(events);
|
|
989
1246
|
const notes = noteRecordsFromEvents(events);
|
|
990
|
-
const
|
|
1247
|
+
const tasks = taskRecordsFromEvents(events);
|
|
991
1248
|
const warning = scan.invalid.length
|
|
992
1249
|
? `> Warning: ${scan.invalid.length} unsupported or malformed event(s) omitted from this Markdown view: ${scan.invalid.map((item) => item.id).join(', ')}\n\n`
|
|
993
1250
|
: '';
|
|
@@ -995,7 +1252,7 @@ export class SynomemClient extends SynomemCore {
|
|
|
995
1252
|
...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
1253
|
...memos.map((record) => `## Memo: ${escapeMarkdown(record.event.subject)}\n\n${escapeMarkdown(record.event.body)}\n\nStatus: ${record.status}\n\nID: \`${record.event.id}\``),
|
|
997
1254
|
...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
|
-
...
|
|
1255
|
+
...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
1256
|
];
|
|
1000
1257
|
return `${warning}${sections.join('\n\n')}\n`;
|
|
1001
1258
|
}
|