@axiom-lattice/protocols 4.1.0 → 4.1.2
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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +12 -0
- package/dist/index.d.mts +1021 -26
- package/dist/index.d.ts +1021 -26
- package/dist/index.js +448 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +420 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/BindingProtocol.ts +87 -11
- package/src/CapabilityBundleStoreProtocol.ts +78 -0
- package/src/CapabilityRuntimeProtocol.ts +82 -0
- package/src/ChannelInstallationStoreProtocol.ts +23 -4
- package/src/ExactDataSnapshot.ts +119 -0
- package/src/PluginProtocol.ts +59 -7
- package/src/ProjectBotMembershipStoreProtocol.ts +48 -0
- package/src/ProjectMembershipStoreProtocol.ts +58 -0
- package/src/ProjectRoomMessageStoreProtocol.ts +26 -0
- package/src/ProjectRoomProtocol.ts +143 -0
- package/src/ProjectRoomRealtimeProtocol.ts +349 -0
- package/src/ProjectRoomStoreProtocol.ts +16 -0
- package/src/SkillStoreProtocol.ts +30 -0
- package/src/TaskBeliefProtocol.ts +6 -1
- package/src/TaskStoreProtocol.ts +66 -2
- package/src/TaskWorkItemProtocol.ts +138 -0
- package/src/TrustedRunContextProtocol.ts +119 -0
- package/src/WorkspaceStoreProtocol.ts +33 -0
- package/src/__tests__/BindingProtocol.test.ts +36 -0
- package/src/__tests__/ExactDataSnapshot.test.ts +105 -0
- package/src/__tests__/ProjectRoomProtocol.test.ts +48 -0
- package/src/__tests__/ProjectRoomRealtimeProtocol.test.ts +185 -0
- package/src/__tests__/ProjectRoomStores.test.ts +363 -0
- package/src/__tests__/ProjectTaskProtocol.test.ts +29 -0
- package/src/__tests__/TaskWorkItemProtocol.test.ts +111 -0
- package/src/__tests__/TrustedRunContextProtocol.test.ts +265 -0
- package/src/__tests__/capability-bundle-types.test.ts +177 -0
- package/src/index.ts +13 -0
- package/tsconfig.type-tests.json +9 -0
- package/type-tests/task-work-item-store-compatibility.ts +37 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProjectRoomBrokerCapacityError,
|
|
3
|
+
ProjectRoomBusinessEvent,
|
|
4
|
+
ProjectRoomBusinessEventDraft,
|
|
5
|
+
ProjectRoomControlEvent,
|
|
6
|
+
ProjectRoomEventScope,
|
|
7
|
+
ProjectRoomEventSubscription,
|
|
8
|
+
ProjectRoomRealtimeActor,
|
|
9
|
+
} from "../ProjectRoomRealtimeProtocol";
|
|
10
|
+
import { runInNewContext } from "node:vm";
|
|
11
|
+
import {
|
|
12
|
+
isProjectRoomEventId,
|
|
13
|
+
parseProjectRoomEventId,
|
|
14
|
+
ProjectRoomCursorError,
|
|
15
|
+
toProjectRoomPublicBotMembership,
|
|
16
|
+
toProjectRoomPublicMembership,
|
|
17
|
+
toProjectRoomPublicMessage,
|
|
18
|
+
} from "../ProjectRoomRealtimeProtocol";
|
|
19
|
+
|
|
20
|
+
const message: ProjectRoomBusinessEvent = {
|
|
21
|
+
id: "550e8400-e29b-41d4-a716-446655440000:1",
|
|
22
|
+
type: "message.created",
|
|
23
|
+
occurredAt: "2026-09-03T00:00:00.000Z",
|
|
24
|
+
data: {
|
|
25
|
+
message: {
|
|
26
|
+
id: "message-1",
|
|
27
|
+
roomId: "room-1",
|
|
28
|
+
author: { type: "bot", membershipId: "bot-1" },
|
|
29
|
+
content: { type: "text", text: "Complete" },
|
|
30
|
+
mentions: [],
|
|
31
|
+
source: "agent",
|
|
32
|
+
createdAt: "2026-09-03T00:00:00.000Z",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const draft: ProjectRoomBusinessEventDraft = {
|
|
38
|
+
type: "task.changed",
|
|
39
|
+
occurredAt: "2026-09-03T00:00:01.000Z",
|
|
40
|
+
data: {
|
|
41
|
+
taskId: "task-1",
|
|
42
|
+
status: "completed",
|
|
43
|
+
ownerMembershipId: "bot-1",
|
|
44
|
+
updatedAt: "2026-09-03T00:00:01.000Z",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const control: ProjectRoomControlEvent = {
|
|
49
|
+
type: "resync",
|
|
50
|
+
data: { reason: "SERVER_RESTART" },
|
|
51
|
+
};
|
|
52
|
+
const scope: ProjectRoomEventScope = { tenantId: "tenant-1", roomId: "room-1", projectId: "project-1" };
|
|
53
|
+
const actor: ProjectRoomRealtimeActor = {
|
|
54
|
+
tenantId: "tenant-1",
|
|
55
|
+
userId: "user-1",
|
|
56
|
+
projectId: "project-1",
|
|
57
|
+
tokenExpiresAt: 1_788_393_600_000,
|
|
58
|
+
};
|
|
59
|
+
const subscription: ProjectRoomEventSubscription = {
|
|
60
|
+
replay: [message],
|
|
61
|
+
headEventId: message.id,
|
|
62
|
+
unsubscribe: () => undefined,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// @ts-expect-error unsupported business event types are not public protocol values
|
|
66
|
+
const dispatchEvent: ProjectRoomBusinessEventDraft = { type: "dispatch.changed", data: {} };
|
|
67
|
+
// @ts-expect-error control events never carry replay IDs
|
|
68
|
+
const identifiedControl: ProjectRoomControlEvent = { ...control, id: "epoch:1" };
|
|
69
|
+
const internalMessage: ProjectRoomBusinessEvent = {
|
|
70
|
+
...message,
|
|
71
|
+
data: {
|
|
72
|
+
// @ts-expect-error internal assistant identity is not public message data
|
|
73
|
+
message: { ...message.data.message, assistantId: "assistant-1" },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
// @ts-expect-error event IDs are canonical strings, not numbers
|
|
77
|
+
const numericEventId: ProjectRoomBusinessEvent = { ...message, id: 1 };
|
|
78
|
+
|
|
79
|
+
describe("Project Room realtime protocol", () => {
|
|
80
|
+
it("defines the exact public business, control, broker, and actor surfaces", () => {
|
|
81
|
+
expect([message.type, draft.type, control.type]).toEqual([
|
|
82
|
+
"message.created",
|
|
83
|
+
"task.changed",
|
|
84
|
+
"resync",
|
|
85
|
+
]);
|
|
86
|
+
expect(JSON.stringify(message)).not.toContain("assistantId");
|
|
87
|
+
expect(JSON.stringify(message)).not.toContain("sourceId");
|
|
88
|
+
expect(scope).toEqual({ tenantId: "tenant-1", roomId: "room-1", projectId: "project-1" });
|
|
89
|
+
expect(actor).toEqual({
|
|
90
|
+
tenantId: "tenant-1",
|
|
91
|
+
userId: "user-1",
|
|
92
|
+
projectId: "project-1",
|
|
93
|
+
tokenExpiresAt: 1_788_393_600_000,
|
|
94
|
+
});
|
|
95
|
+
expect(subscription.headEventId).toBe(message.id);
|
|
96
|
+
expect(dispatchEvent).toBeDefined();
|
|
97
|
+
expect(identifiedControl).toBeDefined();
|
|
98
|
+
expect(internalMessage).toBeDefined();
|
|
99
|
+
expect(numericEventId).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("maps descriptor-safe internal records to public DTOs", () => {
|
|
103
|
+
const internalMessage = {
|
|
104
|
+
id: "message-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1",
|
|
105
|
+
roomId: "room-1", author: { type: "bot", membershipId: "bot-1", assistantId: "assistant-1" },
|
|
106
|
+
content: { type: "text", text: "Complete" }, mentions: [], source: "agent", sourceId: "reply-1",
|
|
107
|
+
idempotencyKey: "idem-1", createdAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
108
|
+
};
|
|
109
|
+
const internalMembership = {
|
|
110
|
+
id: "membership-1", tenantId: "tenant-1", projectId: "project-1", userId: "user-1",
|
|
111
|
+
role: "viewer", status: "active", joinedAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
112
|
+
updatedAt: new Date("2026-09-03T00:00:01.000Z"),
|
|
113
|
+
};
|
|
114
|
+
const internalBot = {
|
|
115
|
+
id: "bot-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
|
|
116
|
+
assistantId: "assistant-1", role: "specialist", title: "Worker", responsibility: "Build",
|
|
117
|
+
mentionName: "worker", status: "active", roomThreadId: "thread-1",
|
|
118
|
+
joinedAt: new Date("2026-09-03T00:00:00.000Z"), updatedAt: new Date("2026-09-03T00:00:01.000Z"),
|
|
119
|
+
};
|
|
120
|
+
expect(toProjectRoomPublicMessage(internalMessage)).toEqual(message.data.message);
|
|
121
|
+
expect(toProjectRoomPublicMembership(internalMembership)).toEqual({
|
|
122
|
+
id: "membership-1", userId: "user-1", role: "viewer", status: "active",
|
|
123
|
+
joinedAt: "2026-09-03T00:00:00.000Z", updatedAt: "2026-09-03T00:00:01.000Z",
|
|
124
|
+
});
|
|
125
|
+
expect(toProjectRoomPublicBotMembership(internalBot)).toEqual({
|
|
126
|
+
id: "bot-1", role: "specialist", title: "Worker", responsibility: "Build", mentionName: "worker",
|
|
127
|
+
status: "active", joinedAt: "2026-09-03T00:00:00.000Z", updatedAt: "2026-09-03T00:00:01.000Z",
|
|
128
|
+
});
|
|
129
|
+
expect(JSON.stringify(toProjectRoomPublicMessage(internalMessage))).not.toMatch(
|
|
130
|
+
/tenantId|workspaceId|projectId|assistantId|thread|idempotency|sourceId/,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects malformed, accessor-backed, and extra internal records", () => {
|
|
135
|
+
expect(toProjectRoomPublicMessage({})).toBeUndefined();
|
|
136
|
+
expect(toProjectRoomPublicMembership({ id: "membership-1", extra: true })).toBeUndefined();
|
|
137
|
+
expect(toProjectRoomPublicBotMembership({})).toBeUndefined();
|
|
138
|
+
const accessor = { get id() { return "message-1"; } };
|
|
139
|
+
expect(toProjectRoomPublicMessage(accessor)).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("reconstructs strict mention DTOs and rejects sensitive mention fields", () => {
|
|
143
|
+
const value = {
|
|
144
|
+
id: "message-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
|
|
145
|
+
author: { type: "human", userId: "user-1" }, content: { type: "text", text: "hello" }, source: "user", createdAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
146
|
+
mentions: [{ type: "team" }, { type: "bot", membershipId: "bot-1" }],
|
|
147
|
+
};
|
|
148
|
+
const result = toProjectRoomPublicMessage(value);
|
|
149
|
+
expect(result?.mentions).toEqual([{ type: "team" }, { type: "bot", membershipId: "bot-1" }]);
|
|
150
|
+
expect(result?.mentions[0]).not.toBe(value.mentions[0]);
|
|
151
|
+
expect(toProjectRoomPublicMessage({ ...value, mentions: [{ type: "team", tenantId: "secret" }] })).toBeUndefined();
|
|
152
|
+
let reads = 0;
|
|
153
|
+
const accessor = Object.defineProperty({ type: "team" }, "type", { enumerable: true, get: () => { reads += 1; return "team"; } });
|
|
154
|
+
expect(toProjectRoomPublicMessage({ ...value, mentions: [accessor] })).toBeUndefined();
|
|
155
|
+
expect(reads).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("parses only canonical safe-integer event IDs", () => {
|
|
159
|
+
expect(parseProjectRoomEventId(message.id)).toEqual({
|
|
160
|
+
epoch: "550e8400-e29b-41d4-a716-446655440000",
|
|
161
|
+
sequence: 1,
|
|
162
|
+
});
|
|
163
|
+
expect(isProjectRoomEventId(message.id)).toBe(true);
|
|
164
|
+
expect(isProjectRoomEventId("epoch:1")).toBe(false);
|
|
165
|
+
expect(isProjectRoomEventId("550E8400-E29B-41D4-A716-446655440000:1")).toBe(false);
|
|
166
|
+
expect(isProjectRoomEventId(`${message.id}x`)).toBe(false);
|
|
167
|
+
expect(isProjectRoomEventId(`${message.id.split(":")[0]}:1.0`)).toBe(false);
|
|
168
|
+
expect(isProjectRoomEventId(`${message.id.split(":")[0]}:${Number.MAX_SAFE_INTEGER + 1}`)).toBe(false);
|
|
169
|
+
expect(parseProjectRoomEventId("not-an-event-id")).toBeUndefined();
|
|
170
|
+
expect(isProjectRoomEventId(runInNewContext(`"${message.id}"`))).toBe(true);
|
|
171
|
+
expect(isProjectRoomEventId(runInNewContext(`new String("${message.id}")`))).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("exposes typed cursor and capacity errors", () => {
|
|
175
|
+
const cursorError = new ProjectRoomCursorError("CURSOR_EXPIRED");
|
|
176
|
+
const capacityError: ProjectRoomBrokerCapacityError = {
|
|
177
|
+
name: "ProjectRoomBrokerCapacityError",
|
|
178
|
+
message: "sequence exhausted",
|
|
179
|
+
code: "PROJECT_ROOM_EVENT_SEQUENCE_EXHAUSTED",
|
|
180
|
+
};
|
|
181
|
+
expect(cursorError).toBeInstanceOf(Error);
|
|
182
|
+
expect(cursorError.code).toBe("CURSOR_EXPIRED");
|
|
183
|
+
expect(capacityError.code).toBe("PROJECT_ROOM_EVENT_SEQUENCE_EXHAUSTED");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProjectBotMembership,
|
|
3
|
+
ProjectBotMembershipStore,
|
|
4
|
+
ProjectMembership,
|
|
5
|
+
ProjectMembershipStore,
|
|
6
|
+
ProjectRoom,
|
|
7
|
+
ProjectRoomMessage,
|
|
8
|
+
ProjectRoomMessageCursor,
|
|
9
|
+
ProjectRoomMessageStore,
|
|
10
|
+
ProjectRoomStore,
|
|
11
|
+
} from "../index";
|
|
12
|
+
|
|
13
|
+
type RoomInput = Parameters<ProjectRoomStore["ensureMainRoom"]>[0];
|
|
14
|
+
type RoomLookupResult = Awaited<ReturnType<ProjectRoomStore["getMainRoom"]>>;
|
|
15
|
+
type MembershipInput = Parameters<ProjectMembershipStore["create"]>[0];
|
|
16
|
+
type InitialOwnerInput = Parameters<ProjectMembershipStore["createInitialOwner"]>[0];
|
|
17
|
+
type InitialOwnerResult = Awaited<ReturnType<ProjectMembershipStore["createInitialOwner"]>>;
|
|
18
|
+
type RoleInput = Parameters<ProjectMembershipStore["updateRole"]>[0];
|
|
19
|
+
type RoleResult = Awaited<ReturnType<ProjectMembershipStore["updateRole"]>>;
|
|
20
|
+
type RemoveInput = Parameters<ProjectMembershipStore["remove"]>[0];
|
|
21
|
+
type RemoveResult = Awaited<ReturnType<ProjectMembershipStore["remove"]>>;
|
|
22
|
+
type BotInput = Parameters<ProjectBotMembershipStore["save"]>[0];
|
|
23
|
+
type BotSaveResult = Awaited<ReturnType<ProjectBotMembershipStore["save"]>>;
|
|
24
|
+
type BotUpdateInput = Parameters<ProjectBotMembershipStore["update"]>[0];
|
|
25
|
+
type BotUpdateResult = Awaited<ReturnType<ProjectBotMembershipStore["update"]>>;
|
|
26
|
+
type MessageInput = Parameters<ProjectRoomMessageStore["create"]>[0];
|
|
27
|
+
type IdempotentMessageInput = Parameters<ProjectRoomMessageStore["createIdempotent"]>[0];
|
|
28
|
+
type MessageListInput = Parameters<ProjectRoomMessageStore["list"]>[0];
|
|
29
|
+
|
|
30
|
+
const room: ProjectRoom = {
|
|
31
|
+
id: "room-1",
|
|
32
|
+
tenantId: "tenant-1",
|
|
33
|
+
workspaceId: "workspace-1",
|
|
34
|
+
projectId: "project-1",
|
|
35
|
+
type: "main",
|
|
36
|
+
name: "Main room",
|
|
37
|
+
createdAt: new Date(0),
|
|
38
|
+
updatedAt: new Date(0),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const membership: ProjectMembership = {
|
|
42
|
+
id: "membership-1",
|
|
43
|
+
tenantId: "tenant-1",
|
|
44
|
+
projectId: "project-1",
|
|
45
|
+
userId: "user-1",
|
|
46
|
+
role: "owner",
|
|
47
|
+
status: "active",
|
|
48
|
+
joinedAt: new Date(0),
|
|
49
|
+
updatedAt: new Date(0),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const botMembership: ProjectBotMembership = {
|
|
53
|
+
id: "bot-membership-1",
|
|
54
|
+
tenantId: "tenant-1",
|
|
55
|
+
workspaceId: "workspace-1",
|
|
56
|
+
projectId: "project-1",
|
|
57
|
+
roomId: "room-1",
|
|
58
|
+
assistantId: "assistant-1",
|
|
59
|
+
role: "coordinator",
|
|
60
|
+
title: "Coordinator",
|
|
61
|
+
mentionName: "coordinator",
|
|
62
|
+
status: "active",
|
|
63
|
+
roomThreadId: "thread-1",
|
|
64
|
+
joinedAt: new Date(0),
|
|
65
|
+
updatedAt: new Date(0),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const message: ProjectRoomMessage = {
|
|
69
|
+
id: "message-1",
|
|
70
|
+
tenantId: "tenant-1",
|
|
71
|
+
workspaceId: "workspace-1",
|
|
72
|
+
projectId: "project-1",
|
|
73
|
+
roomId: "room-1",
|
|
74
|
+
author: { type: "system" },
|
|
75
|
+
content: { type: "text", text: "Room ready" },
|
|
76
|
+
mentions: [],
|
|
77
|
+
source: "system",
|
|
78
|
+
createdAt: new Date(0),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function assertNever(value: never): never {
|
|
82
|
+
throw new Error(`Unexpected protocol result: ${String(value)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function expectInitialOwnerResult(result: InitialOwnerResult, expected: InitialOwnerResult["kind"]): void {
|
|
86
|
+
switch (result.kind) {
|
|
87
|
+
case "created":
|
|
88
|
+
case "existing":
|
|
89
|
+
expect(result.kind).toBe(expected);
|
|
90
|
+
expect(result.membership).toEqual(expect.objectContaining({ projectId: "project-1" }));
|
|
91
|
+
return;
|
|
92
|
+
case "already_initialized":
|
|
93
|
+
expect(result.kind).toBe(expected);
|
|
94
|
+
return;
|
|
95
|
+
default:
|
|
96
|
+
assertNever(result);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function expectRoleResult(result: RoleResult, expected: RoleResult["kind"]): void {
|
|
101
|
+
switch (result.kind) {
|
|
102
|
+
case "updated":
|
|
103
|
+
expect(result.kind).toBe(expected);
|
|
104
|
+
expect(result.membership).toEqual(expect.objectContaining({ id: "membership-1" }));
|
|
105
|
+
return;
|
|
106
|
+
case "removed":
|
|
107
|
+
expect(result.kind).toBe(expected);
|
|
108
|
+
return;
|
|
109
|
+
case "not_found":
|
|
110
|
+
case "conflict":
|
|
111
|
+
case "last_owner":
|
|
112
|
+
expect(result.kind).toBe(expected);
|
|
113
|
+
return;
|
|
114
|
+
default:
|
|
115
|
+
assertNever(result);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function expectRemoveResult(result: RemoveResult, expected: RemoveResult["kind"]): void {
|
|
120
|
+
switch (result.kind) {
|
|
121
|
+
case "updated":
|
|
122
|
+
expect(result.kind).toBe(expected);
|
|
123
|
+
return;
|
|
124
|
+
case "removed":
|
|
125
|
+
expect(result.kind).toBe(expected);
|
|
126
|
+
expect(result.membership).toEqual(expect.objectContaining({ id: "membership-1", status: "removed" }));
|
|
127
|
+
return;
|
|
128
|
+
case "not_found":
|
|
129
|
+
case "conflict":
|
|
130
|
+
case "last_owner":
|
|
131
|
+
expect(result.kind).toBe(expected);
|
|
132
|
+
return;
|
|
133
|
+
default:
|
|
134
|
+
assertNever(result);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function expectBotSaveResult(result: BotSaveResult, expected: BotSaveResult["kind"]): void {
|
|
139
|
+
switch (result.kind) {
|
|
140
|
+
case "created":
|
|
141
|
+
case "updated":
|
|
142
|
+
case "reactivated":
|
|
143
|
+
expect(result.kind).toBe(expected);
|
|
144
|
+
expect(result.membership).toEqual(expect.objectContaining({ projectId: "project-1" }));
|
|
145
|
+
return;
|
|
146
|
+
case "coordinator_conflict":
|
|
147
|
+
case "mention_conflict":
|
|
148
|
+
expect(result.kind).toBe(expected);
|
|
149
|
+
return;
|
|
150
|
+
default:
|
|
151
|
+
assertNever(result);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function expectBotUpdateResult(result: BotUpdateResult, expected: BotUpdateResult["kind"]): void {
|
|
156
|
+
switch (result.kind) {
|
|
157
|
+
case "updated":
|
|
158
|
+
expect(result.kind).toBe(expected);
|
|
159
|
+
expect(result.membership).toEqual(expect.objectContaining({ id: "bot-membership-1" }));
|
|
160
|
+
return;
|
|
161
|
+
case "not_found":
|
|
162
|
+
case "conflict":
|
|
163
|
+
case "coordinator_conflict":
|
|
164
|
+
case "mention_conflict":
|
|
165
|
+
expect(result.kind).toBe(expected);
|
|
166
|
+
return;
|
|
167
|
+
default:
|
|
168
|
+
assertNever(result);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
class FakeProjectRoomStore implements ProjectRoomStore {
|
|
173
|
+
async ensureMainRoom(input: RoomInput): Promise<Awaited<ReturnType<ProjectRoomStore["ensureMainRoom"]>>> {
|
|
174
|
+
return { ...room, ...input };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async getMainRoom(...args: Parameters<ProjectRoomStore["getMainRoom"]>): Promise<RoomLookupResult> {
|
|
178
|
+
const [tenantId, projectId] = args;
|
|
179
|
+
return tenantId === "tenant-1" && projectId === "project-1" ? room : null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
class FakeProjectMembershipStore implements ProjectMembershipStore {
|
|
184
|
+
async list(...args: Parameters<ProjectMembershipStore["list"]>): Promise<Awaited<ReturnType<ProjectMembershipStore["list"]>>> {
|
|
185
|
+
const [tenantId, projectId] = args;
|
|
186
|
+
return tenantId === "tenant-1" && projectId === "project-1" ? [membership] : [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async findByUser(...args: Parameters<ProjectMembershipStore["findByUser"]>): Promise<Awaited<ReturnType<ProjectMembershipStore["findByUser"]>>> {
|
|
190
|
+
const [tenantId, projectId, userId] = args;
|
|
191
|
+
return tenantId === "tenant-1" && projectId === "project-1" && userId === "user-1" ? membership : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async create(input: MembershipInput): Promise<Awaited<ReturnType<ProjectMembershipStore["create"]>>> {
|
|
195
|
+
return { ...input, joinedAt: new Date(0), updatedAt: new Date(0) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async createInitialOwner(input: InitialOwnerInput): Promise<InitialOwnerResult> {
|
|
199
|
+
if (input.id === "already-initialized") return { kind: "already_initialized" };
|
|
200
|
+
return { kind: input.id === "existing" ? "existing" : "created", membership: { ...membership, ...input } };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async updateRole(input: RoleInput): Promise<RoleResult> {
|
|
204
|
+
if (input.id === "not-found") return { kind: "not_found" };
|
|
205
|
+
if (input.id === "conflict") return { kind: "conflict" };
|
|
206
|
+
if (input.id === "last-owner") return { kind: "last_owner" };
|
|
207
|
+
return { kind: "updated", membership };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async remove(input: RemoveInput): Promise<RemoveResult> {
|
|
211
|
+
if (input.id === "not-found") return { kind: "not_found" };
|
|
212
|
+
if (input.id === "conflict") return { kind: "conflict" };
|
|
213
|
+
if (input.id === "last-owner") return { kind: "last_owner" };
|
|
214
|
+
return { kind: "removed", membership: { ...membership, status: "removed" } };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
class FakeProjectBotMembershipStore implements ProjectBotMembershipStore {
|
|
219
|
+
async list(...args: Parameters<ProjectBotMembershipStore["list"]>): Promise<Awaited<ReturnType<ProjectBotMembershipStore["list"]>>> {
|
|
220
|
+
const [tenantId, projectId] = args;
|
|
221
|
+
return tenantId === "tenant-1" && projectId === "project-1" ? [botMembership] : [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async findById(...args: Parameters<ProjectBotMembershipStore["findById"]>): Promise<Awaited<ReturnType<ProjectBotMembershipStore["findById"]>>> {
|
|
225
|
+
const [tenantId, id] = args;
|
|
226
|
+
return tenantId === "tenant-1" && id === "bot-membership-1" ? botMembership : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async findByAssistant(...args: Parameters<ProjectBotMembershipStore["findByAssistant"]>): Promise<Awaited<ReturnType<ProjectBotMembershipStore["findByAssistant"]>>> {
|
|
230
|
+
const [tenantId, projectId, assistantId] = args;
|
|
231
|
+
return tenantId === "tenant-1" && projectId === "project-1" && assistantId === "assistant-1" ? botMembership : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async save(input: BotInput): Promise<BotSaveResult> {
|
|
235
|
+
if (input.id === "coordinator-conflict") return { kind: "coordinator_conflict" };
|
|
236
|
+
if (input.id === "mention-conflict") return { kind: "mention_conflict" };
|
|
237
|
+
const kind = input.id === "updated" ? "updated" : input.id === "reactivated" ? "reactivated" : "created";
|
|
238
|
+
return { kind, membership: { ...input, joinedAt: new Date(0), updatedAt: new Date(0) } };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async update(input: BotUpdateInput): Promise<BotUpdateResult> {
|
|
242
|
+
if (input.id === "not-found") return { kind: "not_found" };
|
|
243
|
+
if (input.id === "conflict") return { kind: "conflict" };
|
|
244
|
+
if (input.id === "coordinator-conflict") return { kind: "coordinator_conflict" };
|
|
245
|
+
if (input.id === "mention-conflict") return { kind: "mention_conflict" };
|
|
246
|
+
return { kind: "updated", membership: { ...botMembership, ...input.patch } };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class FakeProjectRoomMessageStore implements ProjectRoomMessageStore {
|
|
251
|
+
async create(input: MessageInput): Promise<Awaited<ReturnType<ProjectRoomMessageStore["create"]>>> {
|
|
252
|
+
return { ...input, createdAt: new Date(0) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async createIdempotent(input: IdempotentMessageInput): Promise<Awaited<ReturnType<ProjectRoomMessageStore["createIdempotent"]>>> {
|
|
256
|
+
return { ...input, createdAt: new Date(0) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async list(input: MessageListInput): Promise<Awaited<ReturnType<ProjectRoomMessageStore["list"]>>> {
|
|
260
|
+
return input.tenantId === "tenant-1" && input.roomId === "room-1" && input.limit > 0 ? [message] : [];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async findById(...args: Parameters<ProjectRoomMessageStore["findById"]>): Promise<Awaited<ReturnType<ProjectRoomMessageStore["findById"]>>> {
|
|
264
|
+
const [tenantId, id] = args;
|
|
265
|
+
return tenantId === "tenant-1" && id === "message-1" ? message : null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
describe("ProjectRoomStore", () => {
|
|
270
|
+
const store: ProjectRoomStore = new FakeProjectRoomStore();
|
|
271
|
+
|
|
272
|
+
it("creates and looks up the main room, including a missing room", async () => {
|
|
273
|
+
expect(await store.ensureMainRoom(room)).toEqual(room);
|
|
274
|
+
expect(await store.getMainRoom("tenant-1", "project-1")).toEqual(room);
|
|
275
|
+
expect(await store.getMainRoom("tenant-1", "missing-project")).toBeNull();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("ProjectMembershipStore", () => {
|
|
280
|
+
const store: ProjectMembershipStore = new FakeProjectMembershipStore();
|
|
281
|
+
|
|
282
|
+
it("lists, creates, and returns nullable user membership lookups", async () => {
|
|
283
|
+
expect(await store.list("tenant-1", "project-1")).toEqual([membership]);
|
|
284
|
+
expect(await store.findByUser("tenant-1", "project-1", "user-1")).toEqual(membership);
|
|
285
|
+
expect(await store.findByUser("tenant-1", "project-1", "missing-user")).toBeNull();
|
|
286
|
+
expect(await store.create(membership)).toEqual(membership);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("distinguishes initial-owner outcomes", async () => {
|
|
290
|
+
expectInitialOwnerResult(await store.createInitialOwner(membership), "created");
|
|
291
|
+
expectInitialOwnerResult(await store.createInitialOwner({ ...membership, id: "existing" }), "existing");
|
|
292
|
+
expectInitialOwnerResult(await store.createInitialOwner({ ...membership, id: "already-initialized" }), "already_initialized");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("returns every role update result", async () => {
|
|
296
|
+
const cases: ReadonlyArray<readonly [string, RoleResult["kind"]]> = [
|
|
297
|
+
["membership-1", "updated"], ["not-found", "not_found"], ["conflict", "conflict"], ["last-owner", "last_owner"],
|
|
298
|
+
];
|
|
299
|
+
for (const [id, expected] of cases) {
|
|
300
|
+
expectRoleResult(await store.updateRole({ tenantId: "tenant-1", id, role: "admin", expectedUpdatedAt: new Date(0) }), expected);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("returns every membership removal result", async () => {
|
|
305
|
+
const cases: ReadonlyArray<readonly [string, RemoveResult["kind"]]> = [
|
|
306
|
+
["membership-1", "removed"], ["not-found", "not_found"], ["conflict", "conflict"], ["last-owner", "last_owner"],
|
|
307
|
+
];
|
|
308
|
+
for (const [id, expected] of cases) {
|
|
309
|
+
expectRemoveResult(await store.remove({ tenantId: "tenant-1", id, expectedUpdatedAt: new Date(0) }), expected);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("ProjectBotMembershipStore", () => {
|
|
315
|
+
const store: ProjectBotMembershipStore = new FakeProjectBotMembershipStore();
|
|
316
|
+
|
|
317
|
+
it("lists memberships and covers nullable bot lookups", async () => {
|
|
318
|
+
expect(await store.list("tenant-1", "project-1")).toEqual([botMembership]);
|
|
319
|
+
expect(await store.findById("tenant-1", "bot-membership-1")).toEqual(botMembership);
|
|
320
|
+
expect(await store.findById("tenant-1", "missing-bot")).toBeNull();
|
|
321
|
+
expect(await store.findByAssistant("tenant-1", "project-1", "assistant-1")).toEqual(botMembership);
|
|
322
|
+
expect(await store.findByAssistant("tenant-1", "project-1", "missing-assistant")).toBeNull();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("returns every save result", async () => {
|
|
326
|
+
const cases: ReadonlyArray<readonly [string, BotSaveResult["kind"]]> = [
|
|
327
|
+
["bot-membership-1", "created"],
|
|
328
|
+
["updated", "updated"],
|
|
329
|
+
["reactivated", "reactivated"],
|
|
330
|
+
["coordinator-conflict", "coordinator_conflict"],
|
|
331
|
+
["mention-conflict", "mention_conflict"],
|
|
332
|
+
];
|
|
333
|
+
for (const [id, expected] of cases) {
|
|
334
|
+
expectBotSaveResult(await store.save({ ...botMembership, id }), expected);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("returns every update result", async () => {
|
|
339
|
+
const cases: ReadonlyArray<readonly [string, BotUpdateResult["kind"]]> = [
|
|
340
|
+
["bot-membership-1", "updated"],
|
|
341
|
+
["not-found", "not_found"],
|
|
342
|
+
["conflict", "conflict"],
|
|
343
|
+
["coordinator-conflict", "coordinator_conflict"],
|
|
344
|
+
["mention-conflict", "mention_conflict"],
|
|
345
|
+
];
|
|
346
|
+
for (const [id, expected] of cases) {
|
|
347
|
+
expectBotUpdateResult(await store.update({ tenantId: "tenant-1", id, patch: { title: "Lead" }, expectedUpdatedAt: new Date(0) }), expected);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("ProjectRoomMessageStore", () => {
|
|
353
|
+
const store: ProjectRoomMessageStore = new FakeProjectRoomMessageStore();
|
|
354
|
+
|
|
355
|
+
it("creates, idempotently creates, pages, and looks up messages", async () => {
|
|
356
|
+
const cursor: ProjectRoomMessageCursor = { createdAt: message.createdAt, id: message.id };
|
|
357
|
+
expect(await store.create(message)).toEqual(message);
|
|
358
|
+
expect(await store.createIdempotent({ ...message, idempotencyKey: "key-1" })).toEqual({ ...message, idempotencyKey: "key-1" });
|
|
359
|
+
expect(await store.list({ tenantId: "tenant-1", roomId: "room-1", before: cursor, limit: 10 })).toEqual([message]);
|
|
360
|
+
expect(await store.findById("tenant-1", "message-1")).toEqual(message);
|
|
361
|
+
expect(await store.findById("tenant-1", "missing-message")).toBeNull();
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ProjectTaskThreadMetadata } from "../ProjectRoomProtocol";
|
|
2
|
+
|
|
3
|
+
describe("Project Task protocol types", () => {
|
|
4
|
+
it("models task execution thread metadata", () => {
|
|
5
|
+
const metadata: ProjectTaskThreadMetadata = {
|
|
6
|
+
source: "project_task",
|
|
7
|
+
kind: "task_execution",
|
|
8
|
+
workspaceId: "workspace-1",
|
|
9
|
+
projectId: "project-1",
|
|
10
|
+
roomId: "room-1",
|
|
11
|
+
membershipId: "member-1",
|
|
12
|
+
assistantId: "agent-1",
|
|
13
|
+
taskId: "task-1",
|
|
14
|
+
parentTaskId: "parent-1",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
expect(metadata).toEqual({
|
|
18
|
+
source: "project_task",
|
|
19
|
+
kind: "task_execution",
|
|
20
|
+
workspaceId: "workspace-1",
|
|
21
|
+
projectId: "project-1",
|
|
22
|
+
roomId: "room-1",
|
|
23
|
+
membershipId: "member-1",
|
|
24
|
+
assistantId: "agent-1",
|
|
25
|
+
taskId: "task-1",
|
|
26
|
+
parentTaskId: "parent-1",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXECUTION_RESULT_EVENT_KEY_PATTERN_SOURCE,
|
|
3
|
+
MAX_PENDING_EXECUTION_RESULTS_LIMIT,
|
|
4
|
+
PROJECT_TASK_LIFECYCLE_ACTIONS,
|
|
5
|
+
ProjectTaskStoreUnsupportedError,
|
|
6
|
+
isExecutionResultEventKey,
|
|
7
|
+
requireProjectTaskWorkItemStore,
|
|
8
|
+
} from "../TaskWorkItemProtocol";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import type {
|
|
11
|
+
TaskWorkItem,
|
|
12
|
+
TaskWorkItemStore,
|
|
13
|
+
} from "../TaskWorkItemProtocol";
|
|
14
|
+
|
|
15
|
+
class LegacyTaskWorkItemStore implements TaskWorkItemStore {
|
|
16
|
+
async create(): Promise<TaskWorkItem> {
|
|
17
|
+
throw new Error("fixture only");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async list(): Promise<TaskWorkItem[]> {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async findByEventKey(): Promise<TaskWorkItem | null> {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async createIfAbsentByEventKey(): Promise<TaskWorkItem> {
|
|
29
|
+
throw new Error("fixture only");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const legacyStore: TaskWorkItemStore = new LegacyTaskWorkItemStore();
|
|
34
|
+
|
|
35
|
+
describe("execution result event keys", () => {
|
|
36
|
+
it("runs the legacy-store compatibility fixture during normal package tests", () => {
|
|
37
|
+
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
|
|
38
|
+
scripts?: Record<string, string>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
expect(packageJson.scripts?.["typecheck:compat"]).toBe(
|
|
42
|
+
"tsc -p tsconfig.type-tests.json",
|
|
43
|
+
);
|
|
44
|
+
expect(packageJson.scripts?.test).toBe("jest && pnpm typecheck:compat");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps stores without the optimized pending query protocol-compatible", () => {
|
|
48
|
+
expect(legacyStore).toBeInstanceOf(LegacyTaskWorkItemStore);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("coexists with Project Task lifecycle protocol constants", () => {
|
|
52
|
+
expect(PROJECT_TASK_LIFECYCLE_ACTIONS).toEqual([
|
|
53
|
+
"in_progress",
|
|
54
|
+
"interrupted",
|
|
55
|
+
"failed",
|
|
56
|
+
"completed",
|
|
57
|
+
"cancelled",
|
|
58
|
+
"reassigned",
|
|
59
|
+
]);
|
|
60
|
+
expect(isExecutionResultEventKey("execution-result:thread:1")).toBe(true);
|
|
61
|
+
expect(MAX_PENDING_EXECUTION_RESULTS_LIMIT).toBe(1_000);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("reports all missing Project Task store methods", () => {
|
|
65
|
+
expect(() => requireProjectTaskWorkItemStore(legacyStore)).toThrow(
|
|
66
|
+
expect.objectContaining({
|
|
67
|
+
code: "PROJECT_TASK_STORE_UNSUPPORTED",
|
|
68
|
+
missingMethods: ["createIfTaskSnapshot", "listProjectLifecycleEvents"],
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
requireProjectTaskWorkItemStore(legacyStore);
|
|
74
|
+
throw new Error("Expected Project Task store refinement to fail");
|
|
75
|
+
} catch (error) {
|
|
76
|
+
expect(error).toBeInstanceOf(ProjectTaskStoreUnsupportedError);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns a complete Project Task store unchanged", () => {
|
|
81
|
+
const projectStore = Object.assign(new LegacyTaskWorkItemStore(), {
|
|
82
|
+
createIfTaskSnapshot: async () => null,
|
|
83
|
+
listProjectLifecycleEvents: async () => [],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(requireProjectTaskWorkItemStore(projectStore)).toBe(projectStore);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it.each([
|
|
90
|
+
"execution-result:550e8400-e29b-41d4-a716-446655440000",
|
|
91
|
+
"execution-result:thread.segment_1:attempt-2",
|
|
92
|
+
])("accepts canonical ASCII key %s", (value) => {
|
|
93
|
+
expect(isExecutionResultEventKey(value)).toBe(true);
|
|
94
|
+
expect(new RegExp(EXECUTION_RESULT_EVENT_KEY_PATTERN_SOURCE).test(value)).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it.each([
|
|
98
|
+
undefined,
|
|
99
|
+
1,
|
|
100
|
+
"execution-result:",
|
|
101
|
+
"wrong-prefix:value",
|
|
102
|
+
"execution-result:internal space",
|
|
103
|
+
"execution-result:non-breaking\u00a0space",
|
|
104
|
+
"execution-result:next-line\u0085",
|
|
105
|
+
"execution-result:em-space\u2003",
|
|
106
|
+
"execution-result:invalid/punctuation",
|
|
107
|
+
"execution-result:query?value",
|
|
108
|
+
])("rejects non-canonical key %p", (value) => {
|
|
109
|
+
expect(isExecutionResultEventKey(value)).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|