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/src/client.ts
CHANGED
|
@@ -10,13 +10,16 @@ import {
|
|
|
10
10
|
noteRecordsFromEvents,
|
|
11
11
|
ProjectionManager,
|
|
12
12
|
recordsFromEvents,
|
|
13
|
+
taskRecordsFromEvents,
|
|
13
14
|
todoRecordsFromEvents,
|
|
14
15
|
} from './projections.js';
|
|
15
16
|
import {
|
|
16
17
|
actorSchema,
|
|
17
|
-
|
|
18
|
+
agentLookupSchema,
|
|
19
|
+
bindRuntimeSchema,
|
|
18
20
|
createAgentSchema,
|
|
19
21
|
createNoteSchema,
|
|
22
|
+
createTaskSchema,
|
|
20
23
|
createTodoSchema,
|
|
21
24
|
changesInputSchema,
|
|
22
25
|
giveKudosSchema,
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
listInputSchema,
|
|
25
28
|
reviseNoteSchema,
|
|
26
29
|
sendMemoSchema,
|
|
30
|
+
updateTaskSchema,
|
|
27
31
|
updateTodoSchema,
|
|
28
32
|
updateAgentSchema,
|
|
29
33
|
} from './schemas.js';
|
|
@@ -39,7 +43,11 @@ import type { SynomemRepository } from './ports/repository.js';
|
|
|
39
43
|
import type { ProjectionWriter } from './ports/projections.js';
|
|
40
44
|
import type {
|
|
41
45
|
ActorIdentity,
|
|
46
|
+
AgentDirectoryEntry,
|
|
42
47
|
AgentProfile,
|
|
48
|
+
AgentResolution,
|
|
49
|
+
AgentRuntimeBinding,
|
|
50
|
+
BindRuntimeInput,
|
|
43
51
|
CreateAgentInput,
|
|
44
52
|
Diagnostic,
|
|
45
53
|
DoctorResult,
|
|
@@ -52,9 +60,13 @@ import type {
|
|
|
52
60
|
CreateNoteResult,
|
|
53
61
|
ReviseNoteInput,
|
|
54
62
|
NoteRecord,
|
|
63
|
+
CreateTaskInput,
|
|
55
64
|
CreateTodoInput,
|
|
56
65
|
CreateTodoResult,
|
|
66
|
+
CreateTaskResult,
|
|
67
|
+
UpdateTaskInput,
|
|
57
68
|
UpdateTodoInput,
|
|
69
|
+
TaskRecord,
|
|
58
70
|
TodoRecord,
|
|
59
71
|
ItemListInput,
|
|
60
72
|
ItemRecord,
|
|
@@ -101,6 +113,11 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
101
113
|
update: (id: string, changes: UpdateAgentInput) => this.updateAgent(id, changes),
|
|
102
114
|
get: (idOrAlias: string) => this.getAgent(idOrAlias),
|
|
103
115
|
list: () => this.listAgents(),
|
|
116
|
+
resolve: (query: string) => this.resolveAgent(query),
|
|
117
|
+
directory: () => this.agentDirectory(),
|
|
118
|
+
bindings: (idOrAlias: string) => this.listRuntimeBindings(idOrAlias),
|
|
119
|
+
bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
|
|
120
|
+
unbindRuntime: (bindingId: string) => this.unbindRuntime(bindingId),
|
|
104
121
|
};
|
|
105
122
|
|
|
106
123
|
readonly kudos = {
|
|
@@ -131,20 +148,74 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
131
148
|
archive: (input: { noteId: string; idempotencyKey?: string }) => this.archiveNote(input),
|
|
132
149
|
};
|
|
133
150
|
|
|
151
|
+
readonly tasks = {
|
|
152
|
+
create: (input: CreateTaskInput) => this.createTask(input),
|
|
153
|
+
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
154
|
+
this.listItems({ ...input, kinds: ['task'] }),
|
|
155
|
+
get: (id: string) => this.getTask(id),
|
|
156
|
+
update: (input: UpdateTaskInput) => this.updateTask(input),
|
|
157
|
+
// A response is optional when accepting and required when rejecting: a
|
|
158
|
+
// refusal without a reason leaves the assigner unable to act on it.
|
|
159
|
+
accept: (input: { taskId: string; response?: string; idempotencyKey?: string }) =>
|
|
160
|
+
this.acceptTask(input),
|
|
161
|
+
reject: (input: { taskId: string; response: string; idempotencyKey?: string }) =>
|
|
162
|
+
this.rejectTask(input),
|
|
163
|
+
complete: (input: { taskId: string; note?: string; idempotencyKey?: string }) =>
|
|
164
|
+
this.completeTask(input),
|
|
165
|
+
reopen: (input: { taskId: string; idempotencyKey?: string }) => this.reopenTask(input),
|
|
166
|
+
cancel: (input: { taskId: string; reason?: string; idempotencyKey?: string }) =>
|
|
167
|
+
this.cancelTask(input),
|
|
168
|
+
};
|
|
169
|
+
|
|
134
170
|
readonly todos = {
|
|
135
171
|
create: (input: CreateTodoInput) => this.createTodo(input),
|
|
136
172
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
137
173
|
this.listItems({ ...input, kinds: ['todo'] }),
|
|
138
174
|
get: (id: string) => this.getTodo(id),
|
|
139
175
|
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
176
|
complete: (input: { todoId: string; note?: string; idempotencyKey?: string }) =>
|
|
144
|
-
this.
|
|
145
|
-
reopen: (input: { todoId: string; idempotencyKey?: string }) =>
|
|
177
|
+
this.todoTransition(input, 'todo.completed'),
|
|
178
|
+
reopen: (input: { todoId: string; idempotencyKey?: string }) =>
|
|
179
|
+
this.todoTransition(input, 'todo.reopened'),
|
|
146
180
|
cancel: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
|
|
147
|
-
this.
|
|
181
|
+
this.todoTransition(input, 'todo.canceled'),
|
|
182
|
+
archive: (input: { todoId: string; idempotencyKey?: string }) =>
|
|
183
|
+
this.todoTransition(input, 'todo.archived'),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Unanswered and overdue discovery.
|
|
188
|
+
*
|
|
189
|
+
* The plan asks for shared work to be observable without treating runtime
|
|
190
|
+
* metadata as a delivery guarantee. These are query states derived from
|
|
191
|
+
* durable events: they say nobody has answered yet, not that the agent was
|
|
192
|
+
* offline, missed a notification, or lacks a capability it claimed.
|
|
193
|
+
*/
|
|
194
|
+
readonly discovery = {
|
|
195
|
+
/**
|
|
196
|
+
* Tasks awaiting acceptance, unread memos, and unacknowledged kudos —
|
|
197
|
+
* optionally only those older than a given age or instant.
|
|
198
|
+
*/
|
|
199
|
+
unanswered: (
|
|
200
|
+
input: Omit<ItemListInput, 'awaitingResponse' | 'pending'> & { olderThanHours?: number } = {},
|
|
201
|
+
) => {
|
|
202
|
+
const { olderThanHours, awaitingSince, ...rest } = input;
|
|
203
|
+
const since =
|
|
204
|
+
awaitingSince ??
|
|
205
|
+
(olderThanHours !== undefined
|
|
206
|
+
? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
|
|
207
|
+
: undefined);
|
|
208
|
+
return this.listItems({
|
|
209
|
+
...rest,
|
|
210
|
+
awaitingResponse: true,
|
|
211
|
+
...(since ? { awaitingSince: since } : {}),
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
/** Open work whose deadline has passed. Defaults to "now". */
|
|
215
|
+
overdue: (input: Omit<ItemListInput, 'overdueAsOf'> & { asOf?: string } = {}) => {
|
|
216
|
+
const { asOf, ...rest } = input;
|
|
217
|
+
return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
|
|
218
|
+
},
|
|
148
219
|
};
|
|
149
220
|
|
|
150
221
|
readonly items = {
|
|
@@ -251,7 +322,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
251
322
|
private async updateAgent(idOrAlias: string, changes: UpdateAgentInput): Promise<AgentProfile> {
|
|
252
323
|
this.checkAbort();
|
|
253
324
|
await this.repository.assertEventCompatibility();
|
|
254
|
-
this.validate(() =>
|
|
325
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
255
326
|
const parsed = this.validate(() => updateAgentSchema.parse(changes));
|
|
256
327
|
const existing = await this.repository.getAgent(idOrAlias);
|
|
257
328
|
if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
@@ -287,7 +358,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
287
358
|
|
|
288
359
|
private async getAgent(idOrAlias: string): Promise<AgentProfile> {
|
|
289
360
|
this.checkAbort();
|
|
290
|
-
this.validate(() =>
|
|
361
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
291
362
|
const profile = await this.repository.getAgent(idOrAlias);
|
|
292
363
|
if (!profile) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
293
364
|
return profile;
|
|
@@ -298,6 +369,77 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
298
369
|
return await this.repository.listAgents();
|
|
299
370
|
}
|
|
300
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Resolves a name without ever choosing between equally valid answers.
|
|
374
|
+
*
|
|
375
|
+
* Callers that want a single agent should treat an empty `match` as a
|
|
376
|
+
* question for the user, not as "not found": `candidates` distinguishes the
|
|
377
|
+
* two cases.
|
|
378
|
+
*/
|
|
379
|
+
private async resolveAgent(query: string): Promise<AgentResolution> {
|
|
380
|
+
this.checkAbort();
|
|
381
|
+
const trimmed = query.trim();
|
|
382
|
+
if (!trimmed) throw new SynomemError('INVALID_INPUT', 'A lookup name is required.');
|
|
383
|
+
const resolved = await this.repository.resolveAgent(trimmed);
|
|
384
|
+
return {
|
|
385
|
+
query: trimmed,
|
|
386
|
+
...(resolved.match ? { match: resolved.match } : {}),
|
|
387
|
+
candidates: resolved.candidates,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private async agentDirectory(): Promise<AgentDirectoryEntry[]> {
|
|
392
|
+
this.checkAbort();
|
|
393
|
+
const profiles = await this.repository.listAgents();
|
|
394
|
+
const entries: AgentDirectoryEntry[] = [];
|
|
395
|
+
for (const profile of profiles) {
|
|
396
|
+
entries.push({
|
|
397
|
+
profile,
|
|
398
|
+
runtimeBindings: await this.repository.listRuntimeBindings(profile.id),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return entries;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async listRuntimeBindings(idOrAlias: string): Promise<AgentRuntimeBinding[]> {
|
|
405
|
+
const profile = await this.getAgent(idOrAlias);
|
|
406
|
+
return await this.repository.listRuntimeBindings(profile.id);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Records where an agent runs. Re-binding the same runtime and profile
|
|
411
|
+
* updates the claim in place rather than accumulating duplicates, because a
|
|
412
|
+
* reinstall is the same agent in the same place, not a second one.
|
|
413
|
+
*/
|
|
414
|
+
private async bindRuntime(input: BindRuntimeInput): Promise<AgentRuntimeBinding> {
|
|
415
|
+
this.checkAbort();
|
|
416
|
+
const parsed = this.validate(() => bindRuntimeSchema.parse(input));
|
|
417
|
+
const profile = await this.getAgent(parsed.agentId);
|
|
418
|
+
await this.repository.bindRuntime({
|
|
419
|
+
id: this.idGenerator(),
|
|
420
|
+
agentId: profile.id,
|
|
421
|
+
...(parsed.installationId !== undefined ? { installationId: parsed.installationId } : {}),
|
|
422
|
+
runtime: parsed.runtime,
|
|
423
|
+
...(parsed.profile !== undefined ? { profile: parsed.profile } : {}),
|
|
424
|
+
...(parsed.capabilities !== undefined ? { capabilities: parsed.capabilities } : {}),
|
|
425
|
+
boundAt: this.now(),
|
|
426
|
+
});
|
|
427
|
+
const bindings = await this.repository.listRuntimeBindings(profile.id);
|
|
428
|
+
const binding = bindings.find(
|
|
429
|
+
(candidate) =>
|
|
430
|
+
candidate.runtime === parsed.runtime &&
|
|
431
|
+
(candidate.profile ?? '') === (parsed.profile ?? '') &&
|
|
432
|
+
(candidate.installationId ?? '') === (parsed.installationId ?? ''),
|
|
433
|
+
);
|
|
434
|
+
if (!binding) throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
|
|
435
|
+
return binding;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private async unbindRuntime(bindingId: string): Promise<boolean> {
|
|
439
|
+
this.checkAbort();
|
|
440
|
+
return await this.repository.unbindRuntime(bindingId);
|
|
441
|
+
}
|
|
442
|
+
|
|
301
443
|
private async giveKudos(input: GiveKudosInput): Promise<GiveKudosResult> {
|
|
302
444
|
this.checkAbort();
|
|
303
445
|
await this.repository.assertEventCompatibility();
|
|
@@ -493,7 +635,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
493
635
|
? 'MEMO_NOT_FOUND'
|
|
494
636
|
: kind === 'note'
|
|
495
637
|
? 'NOTE_NOT_FOUND'
|
|
496
|
-
: kind === '
|
|
638
|
+
: kind === 'task'
|
|
497
639
|
? 'TODO_NOT_FOUND'
|
|
498
640
|
: 'KUDOS_NOT_FOUND';
|
|
499
641
|
throw new SynomemError(code, `Unknown ${kind}: ${id}`);
|
|
@@ -744,21 +886,21 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
744
886
|
return await this.getNoteRecord(input.noteId);
|
|
745
887
|
}
|
|
746
888
|
|
|
747
|
-
private async
|
|
748
|
-
await this.requireVisibleItem(id, '
|
|
749
|
-
const record =
|
|
750
|
-
if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown
|
|
889
|
+
private async getTaskRecord(id: string): Promise<TaskRecord> {
|
|
890
|
+
await this.requireVisibleItem(id, 'task');
|
|
891
|
+
const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
892
|
+
if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
|
|
751
893
|
return record;
|
|
752
894
|
}
|
|
753
|
-
private async
|
|
895
|
+
private async getTask(id: string): Promise<TaskRecord> {
|
|
754
896
|
this.checkAbort();
|
|
755
|
-
return await this.
|
|
897
|
+
return await this.getTaskRecord(id);
|
|
756
898
|
}
|
|
757
899
|
|
|
758
|
-
private async
|
|
900
|
+
private async createTask(input: CreateTaskInput): Promise<CreateTaskResult> {
|
|
759
901
|
this.checkAbort();
|
|
760
902
|
await this.repository.assertEventCompatibility();
|
|
761
|
-
const parsed = this.validate(() =>
|
|
903
|
+
const parsed = this.validate(() => createTaskSchema.parse(input));
|
|
762
904
|
const assigneeId =
|
|
763
905
|
parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
|
|
764
906
|
if (!assigneeId)
|
|
@@ -768,21 +910,21 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
768
910
|
);
|
|
769
911
|
const assignee = await this.repository.getAgent(assigneeId);
|
|
770
912
|
if (!assignee)
|
|
771
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown
|
|
913
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
|
|
772
914
|
if (
|
|
773
915
|
this.actor.kind === 'agent' &&
|
|
774
916
|
this.actor.id !== assignee.id &&
|
|
775
|
-
!this.repository.config.
|
|
917
|
+
!this.repository.config.allowCrossAgentTasks
|
|
776
918
|
)
|
|
777
|
-
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent
|
|
919
|
+
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
|
|
778
920
|
const outcome = await this.repository.transaction(async () => {
|
|
779
|
-
const prior = await this.priorMutation(parsed.idempotencyKey, '
|
|
780
|
-
if (prior?.type === '
|
|
921
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
|
|
922
|
+
if (prior?.type === 'task.created') return { id: prior.id, created: false };
|
|
781
923
|
const id = this.nextId();
|
|
782
924
|
const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
|
|
783
925
|
const event: SynomemEvent = {
|
|
784
926
|
...this.eventBase(id, 1, id),
|
|
785
|
-
type: '
|
|
927
|
+
type: 'task.created',
|
|
786
928
|
assigneeAgentId: assignee.id,
|
|
787
929
|
assigneeDisplayName: assignee.displayName,
|
|
788
930
|
title: parsed.title,
|
|
@@ -801,13 +943,13 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
801
943
|
});
|
|
802
944
|
if (outcome.created) await this.projectionWriter.syncAgent(assignee.id);
|
|
803
945
|
return {
|
|
804
|
-
record: await this.
|
|
946
|
+
record: await this.getTaskRecord(outcome.id),
|
|
805
947
|
created: outcome.created,
|
|
806
948
|
deduplicated: !outcome.created,
|
|
807
949
|
};
|
|
808
950
|
}
|
|
809
951
|
|
|
810
|
-
private
|
|
952
|
+
private assertTaskParticipant(record: TaskRecord): void {
|
|
811
953
|
const isCreator =
|
|
812
954
|
record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
|
|
813
955
|
const isAssignee =
|
|
@@ -815,35 +957,35 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
815
957
|
if (!this.administrative && !isCreator && !isAssignee)
|
|
816
958
|
throw new SynomemError(
|
|
817
959
|
'MUTATION_FORBIDDEN',
|
|
818
|
-
'Only the
|
|
960
|
+
'Only the task creator, assignee, or a human administrator may change it.',
|
|
819
961
|
);
|
|
820
962
|
}
|
|
821
963
|
|
|
822
|
-
private
|
|
964
|
+
private assertTaskAssignee(record: TaskRecord): void {
|
|
823
965
|
if (
|
|
824
966
|
!this.administrative &&
|
|
825
967
|
!(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)
|
|
826
968
|
) {
|
|
827
969
|
throw new SynomemError(
|
|
828
970
|
'MUTATION_FORBIDDEN',
|
|
829
|
-
'Only the assigned agent or a human administrator may accept or reject this
|
|
971
|
+
'Only the assigned agent or a human administrator may accept or reject this task.',
|
|
830
972
|
);
|
|
831
973
|
}
|
|
832
974
|
}
|
|
833
975
|
|
|
834
|
-
private async
|
|
835
|
-
const parsed = this.validate(() =>
|
|
836
|
-
const record = await this.
|
|
837
|
-
this.
|
|
976
|
+
private async updateTask(input: UpdateTaskInput): Promise<TaskRecord> {
|
|
977
|
+
const parsed = this.validate(() => updateTaskSchema.parse(input));
|
|
978
|
+
const record = await this.getTaskRecord(parsed.taskId);
|
|
979
|
+
this.assertTaskParticipant(record);
|
|
838
980
|
if (record.status !== 'open')
|
|
839
|
-
throw new SynomemError('INVALID_INPUT', 'Only open
|
|
981
|
+
throw new SynomemError('INVALID_INPUT', 'Only open tasks can be updated.');
|
|
840
982
|
if (parsed.expectedVersion !== record.current.version)
|
|
841
983
|
throw new SynomemError(
|
|
842
984
|
'REVISION_CONFLICT',
|
|
843
|
-
`Expected
|
|
985
|
+
`Expected task version ${parsed.expectedVersion}; current version is ${record.current.version}.`,
|
|
844
986
|
);
|
|
845
987
|
await this.repository.transaction(async () => {
|
|
846
|
-
const prior = await this.priorMutation(parsed.idempotencyKey, '
|
|
988
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.updated');
|
|
847
989
|
if (prior) return;
|
|
848
990
|
if (
|
|
849
991
|
(await this.repository.nextAggregateVersion(record.event.id)) !==
|
|
@@ -851,13 +993,13 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
851
993
|
)
|
|
852
994
|
throw new SynomemError(
|
|
853
995
|
'REVISION_CONFLICT',
|
|
854
|
-
'The
|
|
996
|
+
'The task changed before this update was stored.',
|
|
855
997
|
);
|
|
856
998
|
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
857
999
|
const event: SynomemEvent = {
|
|
858
1000
|
...this.eventBase(record.event.id, parsed.expectedVersion + 1),
|
|
859
|
-
type: '
|
|
860
|
-
|
|
1001
|
+
type: 'task.updated',
|
|
1002
|
+
taskId: record.event.id,
|
|
861
1003
|
title: parsed.title ?? record.current.title,
|
|
862
1004
|
...(parsed.description !== undefined
|
|
863
1005
|
? { description: parsed.description }
|
|
@@ -875,35 +1017,49 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
875
1017
|
await this.repository.insertEvent(event);
|
|
876
1018
|
});
|
|
877
1019
|
await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
|
|
878
|
-
return await this.
|
|
1020
|
+
return await this.getTaskRecord(parsed.taskId);
|
|
879
1021
|
}
|
|
880
1022
|
|
|
881
|
-
private async
|
|
882
|
-
input: {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1023
|
+
private async taskTransition(
|
|
1024
|
+
input: {
|
|
1025
|
+
taskId: string;
|
|
1026
|
+
idempotencyKey?: string;
|
|
1027
|
+
note?: string;
|
|
1028
|
+
reason?: string;
|
|
1029
|
+
response?: string;
|
|
1030
|
+
},
|
|
1031
|
+
type: 'task.accepted' | 'task.rejected' | 'task.completed' | 'task.reopened' | 'task.canceled',
|
|
1032
|
+
): Promise<TaskRecord> {
|
|
1033
|
+
const record = await this.getTaskRecord(input.taskId);
|
|
1034
|
+
// A rejection must say why. Enforced here as well as in the schema so the
|
|
1035
|
+
// failure names the missing thing rather than surfacing as a parse error.
|
|
1036
|
+
if (type === 'task.rejected' && !input.response?.trim()) {
|
|
1037
|
+
throw new SynomemError(
|
|
1038
|
+
'INVALID_INPUT',
|
|
1039
|
+
'Rejecting a task requires a response explaining why, so the assigner knows whether to reassign it, wait, or change the request.',
|
|
1040
|
+
);
|
|
892
1041
|
}
|
|
893
|
-
if (type === '
|
|
1042
|
+
if (type === 'task.accepted' || type === 'task.rejected') this.assertTaskAssignee(record);
|
|
1043
|
+
else this.assertTaskParticipant(record);
|
|
1044
|
+
if ((type === 'task.accepted' || type === 'task.rejected') && record.status !== 'assigned') {
|
|
1045
|
+
if (type === 'task.accepted' && record.status === 'open') return record;
|
|
1046
|
+
if (type === 'task.rejected' && record.status === 'rejected') return record;
|
|
1047
|
+
throw new SynomemError('INVALID_INPUT', 'Only assigned tasks may be accepted or rejected.');
|
|
1048
|
+
}
|
|
1049
|
+
if (type === 'task.completed' && record.status !== 'open') {
|
|
894
1050
|
if (record.status === 'completed') return record;
|
|
895
1051
|
throw new SynomemError(
|
|
896
1052
|
'INVALID_INPUT',
|
|
897
|
-
'Only accepted or self-created open
|
|
1053
|
+
'Only accepted or self-created open tasks may be completed.',
|
|
898
1054
|
);
|
|
899
1055
|
}
|
|
900
|
-
if (type === '
|
|
1056
|
+
if (type === 'task.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
|
|
901
1057
|
if (record.status === 'open') return record;
|
|
902
|
-
throw new SynomemError('INVALID_INPUT', 'Only completed or canceled
|
|
1058
|
+
throw new SynomemError('INVALID_INPUT', 'Only completed or canceled tasks may be reopened.');
|
|
903
1059
|
}
|
|
904
|
-
if (type === '
|
|
1060
|
+
if (type === 'task.canceled' && record.status !== 'assigned' && record.status !== 'open') {
|
|
905
1061
|
if (record.status === 'canceled') return record;
|
|
906
|
-
throw new SynomemError('INVALID_INPUT', 'Only assigned or open
|
|
1062
|
+
throw new SynomemError('INVALID_INPUT', 'Only assigned or open tasks may be canceled.');
|
|
907
1063
|
}
|
|
908
1064
|
await this.repository.transaction(async () => {
|
|
909
1065
|
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
@@ -913,34 +1069,180 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
913
1069
|
record.event.id,
|
|
914
1070
|
await this.repository.nextAggregateVersion(record.event.id),
|
|
915
1071
|
),
|
|
916
|
-
|
|
1072
|
+
taskId: record.event.id,
|
|
917
1073
|
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
918
1074
|
};
|
|
919
1075
|
const event: SynomemEvent =
|
|
920
|
-
type === '
|
|
1076
|
+
type === 'task.completed'
|
|
921
1077
|
? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
|
|
922
|
-
: type === '
|
|
923
|
-
? { ...base, type,
|
|
924
|
-
:
|
|
1078
|
+
: type === 'task.rejected'
|
|
1079
|
+
? { ...base, type, response: input.response!.trim() }
|
|
1080
|
+
: type === 'task.accepted'
|
|
1081
|
+
? {
|
|
1082
|
+
...base,
|
|
1083
|
+
type,
|
|
1084
|
+
...(input.response ? { response: input.response.trim() } : {}),
|
|
1085
|
+
}
|
|
1086
|
+
: type === 'task.canceled'
|
|
1087
|
+
? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
|
|
1088
|
+
: { ...base, type };
|
|
925
1089
|
await this.repository.insertEvent(event);
|
|
926
1090
|
});
|
|
927
1091
|
await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
|
|
928
|
-
return await this.
|
|
1092
|
+
return await this.getTaskRecord(input.taskId);
|
|
929
1093
|
}
|
|
930
|
-
private
|
|
931
|
-
return this.
|
|
1094
|
+
private completeTask(input: { taskId: string; note?: string; idempotencyKey?: string }) {
|
|
1095
|
+
return this.taskTransition(input, 'task.completed');
|
|
932
1096
|
}
|
|
933
|
-
private
|
|
934
|
-
return this.
|
|
1097
|
+
private acceptTask(input: { taskId: string; response?: string; idempotencyKey?: string }) {
|
|
1098
|
+
return this.taskTransition(input, 'task.accepted');
|
|
935
1099
|
}
|
|
936
|
-
private
|
|
937
|
-
return this.
|
|
1100
|
+
private rejectTask(input: { taskId: string; response: string; idempotencyKey?: string }) {
|
|
1101
|
+
return this.taskTransition(input, 'task.rejected');
|
|
938
1102
|
}
|
|
939
|
-
private
|
|
940
|
-
return this.
|
|
1103
|
+
private reopenTask(input: { taskId: string; idempotencyKey?: string }) {
|
|
1104
|
+
return this.taskTransition(input, 'task.reopened');
|
|
941
1105
|
}
|
|
942
|
-
private
|
|
943
|
-
return this.
|
|
1106
|
+
private cancelTask(input: { taskId: string; reason?: string; idempotencyKey?: string }) {
|
|
1107
|
+
return this.taskTransition(input, 'task.canceled');
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/* ----------------------------------------------------------------- todos *
|
|
1111
|
+
* A Todo is a private reminder an agent creates for itself. There is no
|
|
1112
|
+
* assignee, no acceptance, and no visibility choice: it belongs to its author
|
|
1113
|
+
* and only its author reads it. Every method below asserts that ownership
|
|
1114
|
+
* rather than relying on a visibility filter, so a Todo cannot be reached by
|
|
1115
|
+
* guessing its id.
|
|
1116
|
+
*/
|
|
1117
|
+
|
|
1118
|
+
private async createTodo(input: CreateTodoInput): Promise<CreateTodoResult> {
|
|
1119
|
+
this.checkAbort();
|
|
1120
|
+
await this.repository.assertEventCompatibility();
|
|
1121
|
+
const parsed = this.validate(() => createTodoSchema.parse(input));
|
|
1122
|
+
const outcome = await this.repository.transaction(async () => {
|
|
1123
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
|
|
1124
|
+
if (prior?.type === 'todo.created') return { id: prior.id, created: false };
|
|
1125
|
+
const id = this.nextId();
|
|
1126
|
+
const event: SynomemEvent = {
|
|
1127
|
+
...this.eventBase(id, 1, id),
|
|
1128
|
+
type: 'todo.created',
|
|
1129
|
+
title: parsed.title,
|
|
1130
|
+
...(parsed.details !== undefined ? { details: parsed.details } : {}),
|
|
1131
|
+
priority: parsed.priority ?? 3,
|
|
1132
|
+
...(parsed.due ? { due: parsed.due } : {}),
|
|
1133
|
+
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
1134
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
1135
|
+
...(parsed.source ? { source: parsed.source } : {}),
|
|
1136
|
+
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
1137
|
+
};
|
|
1138
|
+
await this.repository.insertEvent(event);
|
|
1139
|
+
return { id, created: true };
|
|
1140
|
+
});
|
|
1141
|
+
return {
|
|
1142
|
+
record: await this.getTodoRecord(outcome.id),
|
|
1143
|
+
created: outcome.created,
|
|
1144
|
+
deduplicated: !outcome.created,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
private async getTodoRecord(id: string): Promise<TodoRecord> {
|
|
1149
|
+
const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
1150
|
+
if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
|
|
1151
|
+
this.assertTodoOwner(record);
|
|
1152
|
+
return record;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Todos are owner-only. No role, however broad, reads another actor's
|
|
1157
|
+
* private reminders — that is a named administrative capability this release
|
|
1158
|
+
* does not have, not something a permission check quietly allows.
|
|
1159
|
+
*/
|
|
1160
|
+
private assertTodoOwner(record: TodoRecord): void {
|
|
1161
|
+
const owner = record.event.actor;
|
|
1162
|
+
if (owner.kind !== this.actor.kind || owner.id !== this.actor.id) {
|
|
1163
|
+
throw new SynomemError(
|
|
1164
|
+
'MUTATION_FORBIDDEN',
|
|
1165
|
+
'A todo is private to the actor who created it.',
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
private async getTodo(id: string): Promise<TodoRecord> {
|
|
1171
|
+
this.checkAbort();
|
|
1172
|
+
return await this.getTodoRecord(id);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
private async updateTodo(input: UpdateTodoInput): Promise<TodoRecord> {
|
|
1176
|
+
this.checkAbort();
|
|
1177
|
+
const parsed = this.validate(() => updateTodoSchema.parse(input));
|
|
1178
|
+
const record = await this.getTodoRecord(parsed.todoId);
|
|
1179
|
+
if (record.current.version !== parsed.expectedVersion) {
|
|
1180
|
+
throw new SynomemError(
|
|
1181
|
+
'REVISION_CONFLICT',
|
|
1182
|
+
`Todo ${parsed.todoId} is at version ${record.current.version}.`,
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
1186
|
+
await this.repository.transaction(async () => {
|
|
1187
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
|
|
1188
|
+
if (prior) return;
|
|
1189
|
+
const event: SynomemEvent = {
|
|
1190
|
+
...this.eventBase(
|
|
1191
|
+
record.event.id,
|
|
1192
|
+
await this.repository.nextAggregateVersion(record.event.id),
|
|
1193
|
+
),
|
|
1194
|
+
type: 'todo.updated',
|
|
1195
|
+
todoId: record.event.id,
|
|
1196
|
+
title: parsed.title ?? record.current.title,
|
|
1197
|
+
...((parsed.details ?? record.current.details)
|
|
1198
|
+
? { details: parsed.details ?? record.current.details }
|
|
1199
|
+
: {}),
|
|
1200
|
+
priority: parsed.priority ?? record.current.priority,
|
|
1201
|
+
...(due ? { due } : {}),
|
|
1202
|
+
tags: [...new Set<string>(parsed.tags ?? record.current.tags)].sort(),
|
|
1203
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
1204
|
+
};
|
|
1205
|
+
await this.repository.insertEvent(event);
|
|
1206
|
+
});
|
|
1207
|
+
return await this.getTodoRecord(parsed.todoId);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
private async todoTransition(
|
|
1211
|
+
input: { todoId: string; idempotencyKey?: string; note?: string; reason?: string },
|
|
1212
|
+
type: 'todo.completed' | 'todo.reopened' | 'todo.canceled' | 'todo.archived',
|
|
1213
|
+
): Promise<TodoRecord> {
|
|
1214
|
+
const record = await this.getTodoRecord(input.todoId);
|
|
1215
|
+
if (type === 'todo.completed' && record.status !== 'open') {
|
|
1216
|
+
if (record.status === 'completed') return record;
|
|
1217
|
+
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
|
|
1218
|
+
}
|
|
1219
|
+
if (type === 'todo.reopened' && record.status === 'open') return record;
|
|
1220
|
+
if (type === 'todo.canceled' && record.status !== 'open') {
|
|
1221
|
+
if (record.status === 'canceled') return record;
|
|
1222
|
+
throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
|
|
1223
|
+
}
|
|
1224
|
+
if (type === 'todo.archived' && record.status === 'archived') return record;
|
|
1225
|
+
|
|
1226
|
+
await this.repository.transaction(async () => {
|
|
1227
|
+
const prior = await this.priorMutation(input.idempotencyKey, type);
|
|
1228
|
+
if (prior) return;
|
|
1229
|
+
const base = {
|
|
1230
|
+
...this.eventBase(
|
|
1231
|
+
record.event.id,
|
|
1232
|
+
await this.repository.nextAggregateVersion(record.event.id),
|
|
1233
|
+
),
|
|
1234
|
+
todoId: record.event.id,
|
|
1235
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
1236
|
+
};
|
|
1237
|
+
const event: SynomemEvent =
|
|
1238
|
+
type === 'todo.completed'
|
|
1239
|
+
? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
|
|
1240
|
+
: type === 'todo.canceled'
|
|
1241
|
+
? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
|
|
1242
|
+
: { ...base, type };
|
|
1243
|
+
await this.repository.insertEvent(event);
|
|
1244
|
+
});
|
|
1245
|
+
return await this.getTodoRecord(input.todoId);
|
|
944
1246
|
}
|
|
945
1247
|
|
|
946
1248
|
private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
|
|
@@ -976,7 +1278,7 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
976
1278
|
if (summary.kind === 'kudos') return this.getKudos(id);
|
|
977
1279
|
if (summary.kind === 'memo') return this.getMemo(id);
|
|
978
1280
|
if (summary.kind === 'note') return this.getNote(id);
|
|
979
|
-
return this.
|
|
1281
|
+
return this.getTask(id);
|
|
980
1282
|
}
|
|
981
1283
|
|
|
982
1284
|
async stats(input: KudosListInput = {}): Promise<KudosStats> {
|
|
@@ -1035,6 +1337,15 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1035
1337
|
}
|
|
1036
1338
|
}
|
|
1037
1339
|
|
|
1340
|
+
/**
|
|
1341
|
+
* The schema version this package writes and expects. Named rather than
|
|
1342
|
+
* repeated as a literal because it is asserted in three places, and a doctor
|
|
1343
|
+
* check that silently lags the migration runner reports a healthy database as
|
|
1344
|
+
* broken.
|
|
1345
|
+
*/
|
|
1346
|
+
const CURRENT_SCHEMA_VERSION = 5;
|
|
1347
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5];
|
|
1348
|
+
|
|
1038
1349
|
export class SynomemClient extends SynomemCore implements SynomemService {
|
|
1039
1350
|
readonly home: string;
|
|
1040
1351
|
readonly storage: SynomemStorage;
|
|
@@ -1133,8 +1444,9 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1133
1444
|
});
|
|
1134
1445
|
const migrationState = this.storage.migrationState();
|
|
1135
1446
|
const migrationsValid =
|
|
1136
|
-
migrationState.schemaVersion ===
|
|
1137
|
-
JSON.stringify(migrationState.appliedVersions) ===
|
|
1447
|
+
migrationState.schemaVersion === CURRENT_SCHEMA_VERSION &&
|
|
1448
|
+
JSON.stringify(migrationState.appliedVersions) ===
|
|
1449
|
+
JSON.stringify(EXPECTED_APPLIED_MIGRATIONS);
|
|
1138
1450
|
diagnostics.push({
|
|
1139
1451
|
level: migrationsValid ? 'ok' : 'error',
|
|
1140
1452
|
code: migrationsValid ? 'MIGRATIONS_VALID' : 'MIGRATIONS_INCONSISTENT',
|
|
@@ -1201,7 +1513,7 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1201
1513
|
const records = recordsFromEvents(events);
|
|
1202
1514
|
const memos = memoRecordsFromEvents(events);
|
|
1203
1515
|
const notes = noteRecordsFromEvents(events);
|
|
1204
|
-
const
|
|
1516
|
+
const tasks = taskRecordsFromEvents(events);
|
|
1205
1517
|
const warning = scan.invalid.length
|
|
1206
1518
|
? `> Warning: ${scan.invalid.length} unsupported or malformed event(s) omitted from this Markdown view: ${scan.invalid.map((item) => item.id).join(', ')}\n\n`
|
|
1207
1519
|
: '';
|
|
@@ -1218,9 +1530,9 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1218
1530
|
(record) =>
|
|
1219
1531
|
`## 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
1532
|
),
|
|
1221
|
-
...
|
|
1533
|
+
...tasks.map(
|
|
1222
1534
|
(record) =>
|
|
1223
|
-
`##
|
|
1535
|
+
`## 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
1536
|
),
|
|
1225
1537
|
];
|
|
1226
1538
|
return `${warning}${sections.join('\n\n')}\n`;
|