@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.
Files changed (39) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +12 -0
  3. package/dist/index.d.mts +1021 -26
  4. package/dist/index.d.ts +1021 -26
  5. package/dist/index.js +448 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +420 -1
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -2
  10. package/src/BindingProtocol.ts +87 -11
  11. package/src/CapabilityBundleStoreProtocol.ts +78 -0
  12. package/src/CapabilityRuntimeProtocol.ts +82 -0
  13. package/src/ChannelInstallationStoreProtocol.ts +23 -4
  14. package/src/ExactDataSnapshot.ts +119 -0
  15. package/src/PluginProtocol.ts +59 -7
  16. package/src/ProjectBotMembershipStoreProtocol.ts +48 -0
  17. package/src/ProjectMembershipStoreProtocol.ts +58 -0
  18. package/src/ProjectRoomMessageStoreProtocol.ts +26 -0
  19. package/src/ProjectRoomProtocol.ts +143 -0
  20. package/src/ProjectRoomRealtimeProtocol.ts +349 -0
  21. package/src/ProjectRoomStoreProtocol.ts +16 -0
  22. package/src/SkillStoreProtocol.ts +30 -0
  23. package/src/TaskBeliefProtocol.ts +6 -1
  24. package/src/TaskStoreProtocol.ts +66 -2
  25. package/src/TaskWorkItemProtocol.ts +138 -0
  26. package/src/TrustedRunContextProtocol.ts +119 -0
  27. package/src/WorkspaceStoreProtocol.ts +33 -0
  28. package/src/__tests__/BindingProtocol.test.ts +36 -0
  29. package/src/__tests__/ExactDataSnapshot.test.ts +105 -0
  30. package/src/__tests__/ProjectRoomProtocol.test.ts +48 -0
  31. package/src/__tests__/ProjectRoomRealtimeProtocol.test.ts +185 -0
  32. package/src/__tests__/ProjectRoomStores.test.ts +363 -0
  33. package/src/__tests__/ProjectTaskProtocol.test.ts +29 -0
  34. package/src/__tests__/TaskWorkItemProtocol.test.ts +111 -0
  35. package/src/__tests__/TrustedRunContextProtocol.test.ts +265 -0
  36. package/src/__tests__/capability-bundle-types.test.ts +177 -0
  37. package/src/index.ts +13 -0
  38. package/tsconfig.type-tests.json +9 -0
  39. package/type-tests/task-work-item-store-compatibility.ts +37 -0
@@ -366,9 +366,10 @@ export interface TaskListFilter {
366
366
  workspaceId?: string;
367
367
 
368
368
  /**
369
- * Filter by project ID
369
+ * Filter by project ID. `null` matches only absent, empty, or `default` projects;
370
+ * `undefined` applies no project predicate.
370
371
  */
371
- projectId?: string;
372
+ projectId?: string | null;
372
373
 
373
374
  /**
374
375
  * Filter by parent task ID
@@ -396,6 +397,42 @@ export interface TaskListFilter {
396
397
  offset?: number;
397
398
  }
398
399
 
400
+ /**
401
+ * Exact scope and pagination for tasks that depend on another project task.
402
+ */
403
+ export interface TaskDependentListQuery {
404
+ /** Tenant identifier. */
405
+ tenantId: string;
406
+ /** Workspace identifier. */
407
+ workspaceId: string;
408
+ /** Project identifier. */
409
+ projectId: string;
410
+ /** Identifier that must occur as a string in the dependency array. */
411
+ dependencyTaskId: string;
412
+ /** Nonempty statuses eligible for recovery. */
413
+ statuses: TaskItem["status"][];
414
+ /** Maximum rows to return, from 1 through 100. */
415
+ limit: number;
416
+ /** Number of matching rows to skip. */
417
+ offset: number;
418
+ }
419
+
420
+ /** Exact mutable identity captured before a trusted task mutation. */
421
+ export interface TaskMutationSnapshot {
422
+ /** Status observed during authorization. */
423
+ status: TaskItem["status"];
424
+ /** Update timestamp observed during authorization. */
425
+ updatedAt: Date | string;
426
+ /** Owner kind observed during authorization. */
427
+ ownerType: TaskItem["ownerType"];
428
+ /** Owner identifier observed during authorization. */
429
+ ownerId: string;
430
+ /** Exact workspace scope observed during authorization. */
431
+ workspaceId: string | null;
432
+ /** Exact Project scope observed during authorization. */
433
+ projectId: string | null;
434
+ }
435
+
399
436
  /**
400
437
  * TaskStore interface
401
438
  * Provides CRUD operations for task data
@@ -423,6 +460,14 @@ export interface TaskStore {
423
460
  */
424
461
  list(filter: TaskListFilter): Promise<TaskItem[]>;
425
462
 
463
+ /**
464
+ * Lists exact project tasks that depend on another task.
465
+ *
466
+ * @param query Exact scope, statuses, and bounded offset page.
467
+ * @returns Matching tasks ordered by creation time and ID descending.
468
+ */
469
+ listDependents(query: TaskDependentListQuery): Promise<TaskItem[]>;
470
+
426
471
  /**
427
472
  * Update an existing task
428
473
  * @param tenantId Tenant identifier
@@ -473,6 +518,22 @@ export interface TaskStore {
473
518
  expectedUpdatedAt: Date | string,
474
519
  ): Promise<TaskItem | null>;
475
520
 
521
+ /**
522
+ * Atomically updates a task only while its full trusted mutation snapshot matches.
523
+ *
524
+ * @param tenantId Tenant identifier.
525
+ * @param id Task identifier.
526
+ * @param updates Partial task data to update.
527
+ * @param snapshot Exact status, timestamp, owner, and Project scope snapshot.
528
+ * @returns Updated task, or `null` when any snapshot field differs.
529
+ */
530
+ updateIfSnapshot(
531
+ tenantId: string,
532
+ id: string,
533
+ updates: UpdateTaskRequest,
534
+ snapshot: TaskMutationSnapshot,
535
+ ): Promise<TaskItem | null>;
536
+
476
537
  /**
477
538
  * Atomically updates a child only when both child and parent snapshots match.
478
539
  *
@@ -495,6 +556,9 @@ export interface TaskStore {
495
556
  expectedParentUpdatedAt: Date | string,
496
557
  ): Promise<TaskItem | null>;
497
558
 
559
+ /** Atomically deletes a task only while its full trusted mutation snapshot matches. */
560
+ deleteIfSnapshot(tenantId: string, id: string, snapshot: TaskMutationSnapshot): Promise<boolean>;
561
+
498
562
  /**
499
563
  * Atomically update a task unless its current status is blocked.
500
564
  *
@@ -1,3 +1,5 @@
1
+ import type { TaskMutationSnapshot } from "./TaskStoreProtocol";
2
+
1
3
  /**
2
4
  * TaskWorkItemProtocol
3
5
  *
@@ -56,10 +58,107 @@ export interface TaskWorkItemListFilter {
56
58
  offset?: number;
57
59
  }
58
60
 
61
+ /** Canonical prefix for public task execution-result event identities. */
62
+ export const EXECUTION_RESULT_EVENT_KEY_PREFIX = "execution-result:";
63
+
64
+ /**
65
+ * Portable regular-expression source for canonical execution-result event keys.
66
+ *
67
+ * The entire key is the literal `execution-result:` prefix followed by a nonempty
68
+ * suffix containing only ASCII letters, digits, period, underscore, colon, or hyphen.
69
+ * Colon is intentionally allowed so callers can compose structured suffixes.
70
+ */
71
+ export const EXECUTION_RESULT_EVENT_KEY_PATTERN_SOURCE =
72
+ "^execution-result:[A-Za-z0-9._:-]+$";
73
+
74
+ /** Compiled runtime expression for canonical execution-result event keys. */
75
+ export const EXECUTION_RESULT_EVENT_KEY_PATTERN =
76
+ new RegExp(EXECUTION_RESULT_EVENT_KEY_PATTERN_SOURCE);
77
+
78
+ /** Maximum pending execution-result rows accepted by one store query. */
79
+ export const MAX_PENDING_EXECUTION_RESULTS_LIMIT = 1_000;
80
+
81
+ /**
82
+ * Determines whether a runtime value is a canonical execution-result event key.
83
+ *
84
+ * @param value Runtime value to validate.
85
+ * @returns True only for the portable canonical ASCII grammar.
86
+ */
87
+ export function isExecutionResultEventKey(value: unknown): value is string {
88
+ return typeof value === "string" && EXECUTION_RESULT_EVENT_KEY_PATTERN.test(value);
89
+ }
90
+
91
+ /** Canonical lifecycle actions projected into Project Rooms. */
92
+ export const PROJECT_TASK_LIFECYCLE_ACTIONS = [
93
+ "in_progress", "interrupted", "failed", "completed", "cancelled", "reassigned",
94
+ ] as const;
95
+
96
+ /** Lifecycle action eligible for Project Room projection. */
97
+ export type ProjectTaskLifecycleAction = typeof PROJECT_TASK_LIFECYCLE_ACTIONS[number];
98
+
99
+ /** Exclusive cursor for deterministic project lifecycle pagination. */
100
+ export interface ProjectLifecycleEventCursor {
101
+ /** Creation timestamp of the last returned event. */
102
+ createdAt: Date;
103
+ /** Identifier of the last returned event. */
104
+ id: string;
105
+ }
106
+
107
+ /** Exact project scope and bounded page for canonical lifecycle events. */
108
+ export interface ProjectLifecycleEventQuery {
109
+ /** Tenant identifier. */
110
+ tenantId: string;
111
+ /** Workspace identifier. */
112
+ workspaceId: string;
113
+ /** Project identifier. */
114
+ projectId: string;
115
+ /** Nonempty lifecycle actions to include. */
116
+ actions: ProjectTaskLifecycleAction[];
117
+ /** Optional exclusive descending cursor. */
118
+ before?: ProjectLifecycleEventCursor;
119
+ /** Maximum rows to return, from 1 through 100. */
120
+ limit: number;
121
+ }
122
+
59
123
  export interface TaskWorkItemStore {
60
124
  create(params: CreateWorkItemRequest): Promise<TaskWorkItem>;
125
+ /** Atomically creates a work item only while the owning task snapshot matches. */
126
+ createIfTaskSnapshot?(
127
+ params: CreateWorkItemRequest,
128
+ snapshot: TaskMutationSnapshot,
129
+ ): Promise<TaskWorkItem | null>;
61
130
  list(filter: TaskWorkItemListFilter): Promise<TaskWorkItem[]>;
62
131
 
132
+ /**
133
+ * List the newest bounded set of execution results awaiting reconciliation.
134
+ *
135
+ * Only `execution_result` items with a canonical ASCII
136
+ * `execution-result:[A-Za-z0-9._:-]+` event key are returned. An item is excluded when
137
+ * a task-scoped `execution_reconciled` item has a
138
+ * `detail.executionResultId` equal to that event key. Results are ordered by
139
+ * `createdAt` descending and then `id` descending for deterministic ties.
140
+ *
141
+ * @param params Tenant/task scope and required maximum number of rows.
142
+ * @returns At most `limit` pending execution-result work items, newest first.
143
+ * @throws RangeError with code `INVALID_LIMIT` unless limit is a safe integer from zero through
144
+ * {@link MAX_PENDING_EXECUTION_RESULTS_LIMIT}.
145
+ * @remarks Optional optimization. Stores that omit it remain compatible; callers may use a
146
+ * bounded, non-authoritative fallback through the pre-existing list and event-key methods.
147
+ */
148
+ listPendingExecutionResults?(params: {
149
+ tenantId: string;
150
+ taskId: string;
151
+ limit: number;
152
+ }): Promise<TaskWorkItem[]>;
153
+
154
+ /**
155
+ * Lists canonical lifecycle events in an exact project scope.
156
+ *
157
+ * @param query Exact scope, actions, exclusive cursor, and page bound.
158
+ * @returns Events ordered by creation time and ID descending.
159
+ */
160
+ listProjectLifecycleEvents?(query: ProjectLifecycleEventQuery): Promise<TaskWorkItem[]>;
161
+
63
162
  /**
64
163
  * Find an event by deterministic identity without list pagination.
65
164
  *
@@ -79,3 +178,42 @@ export interface TaskWorkItemStore {
79
178
  */
80
179
  createIfAbsentByEventKey(params: CreateWorkItemIfAbsentRequest): Promise<TaskWorkItem>;
81
180
  }
181
+
182
+ /** Work-item capabilities required by trusted Project Task consumers. */
183
+ export interface ProjectTaskWorkItemStore extends TaskWorkItemStore {
184
+ createIfTaskSnapshot(
185
+ params: CreateWorkItemRequest,
186
+ snapshot: TaskMutationSnapshot,
187
+ ): Promise<TaskWorkItem | null>;
188
+ listProjectLifecycleEvents(
189
+ query: ProjectLifecycleEventQuery,
190
+ ): Promise<TaskWorkItem[]>;
191
+ }
192
+
193
+ /** Stable capability failure raised before any trusted Project Task mutation. */
194
+ export class ProjectTaskStoreUnsupportedError extends Error {
195
+ readonly code = "PROJECT_TASK_STORE_UNSUPPORTED" as const;
196
+
197
+ constructor(readonly missingMethods: readonly string[]) {
198
+ super(`Project Task WorkItem store is missing: ${missingMethods.join(", ")}`);
199
+ this.name = "ProjectTaskStoreUnsupportedError";
200
+ }
201
+ }
202
+
203
+ /** Refines a Main-compatible WorkItem store for trusted Project Task consumers. */
204
+ export function requireProjectTaskWorkItemStore(
205
+ store: TaskWorkItemStore,
206
+ ): ProjectTaskWorkItemStore {
207
+ const missingMethods = ["createIfTaskSnapshot", "listProjectLifecycleEvents"]
208
+ .filter((name) => {
209
+ try {
210
+ return typeof Reflect.get(store as object, name) !== "function";
211
+ } catch {
212
+ return true;
213
+ }
214
+ });
215
+ if (missingMethods.length > 0) {
216
+ throw new ProjectTaskStoreUnsupportedError(missingMethods);
217
+ }
218
+ return store as ProjectTaskWorkItemStore;
219
+ }
@@ -0,0 +1,119 @@
1
+ import { snapshotExactRecord } from "./ExactDataSnapshot";
2
+
3
+ /** Queue execution behavior available to privileged host dispatchers. */
4
+ export type QueuedExecutionMode = "followup";
5
+
6
+ /** Trusted Project Room identity persisted with a privileged queue message. */
7
+ export interface ProjectRoomTrustedRunContext {
8
+ tenantId: string;
9
+ workspaceId: string;
10
+ projectId: string;
11
+ roomId: string;
12
+ sourceRoomMessageId: string;
13
+ membershipId: string;
14
+ assistantId: string;
15
+ inputMessageId: string;
16
+ role: "coordinator" | "specialist";
17
+ title: string;
18
+ responsibility?: string;
19
+ }
20
+
21
+ /** Trusted Project Task identity persisted with a privileged queue message. */
22
+ export interface ProjectTaskTrustedRunContext {
23
+ tenantId: string;
24
+ workspaceId: string;
25
+ projectId: string;
26
+ roomId: string;
27
+ membershipId: string;
28
+ assistantId: string;
29
+ taskId: string;
30
+ threadId: string;
31
+ inputMessageId: string;
32
+ }
33
+
34
+ /** Host-authenticated metadata that cannot be supplied through public Agent APIs. */
35
+ export interface TrustedRunContext {
36
+ projectRoom?: ProjectRoomTrustedRunContext;
37
+ projectTask?: ProjectTaskTrustedRunContext;
38
+ }
39
+
40
+ /**
41
+ * Strictly validates and clones host-authenticated queue context read from durable storage.
42
+ *
43
+ * @param value - Untrusted decoded database value.
44
+ * @returns A validated defensive clone of the trusted run context.
45
+ * @throws Error when the stored value does not exactly match the trusted context contract.
46
+ */
47
+ export function parseTrustedRunContext(value: unknown): TrustedRunContext {
48
+ const contextValues = snapshotExactRecord(value, [], ["projectRoom", "projectTask"]);
49
+ if (!contextValues || Object.keys(contextValues).length !== 1) {
50
+ throw new Error("Invalid trusted agent run context");
51
+ }
52
+ if (Object.prototype.hasOwnProperty.call(contextValues, "projectTask")) {
53
+ const requiredKeys = [
54
+ "tenantId", "workspaceId", "projectId", "roomId", "membershipId",
55
+ "assistantId", "taskId", "threadId", "inputMessageId",
56
+ ] as const;
57
+ const projectTaskValues = snapshotExactRecord(contextValues.projectTask, requiredKeys);
58
+ if (!projectTaskValues
59
+ || requiredKeys.some((key) => typeof projectTaskValues[key] !== "string" || projectTaskValues[key].length === 0)) {
60
+ throw new Error("Invalid trusted agent run context");
61
+ }
62
+ return {
63
+ projectTask: {
64
+ tenantId: projectTaskValues.tenantId as string,
65
+ workspaceId: projectTaskValues.workspaceId as string,
66
+ projectId: projectTaskValues.projectId as string,
67
+ roomId: projectTaskValues.roomId as string,
68
+ membershipId: projectTaskValues.membershipId as string,
69
+ assistantId: projectTaskValues.assistantId as string,
70
+ taskId: projectTaskValues.taskId as string,
71
+ threadId: projectTaskValues.threadId as string,
72
+ inputMessageId: projectTaskValues.inputMessageId as string,
73
+ },
74
+ };
75
+ }
76
+ const projectRoomValue = contextValues?.projectRoom;
77
+ const requiredKeys = [
78
+ "tenantId", "workspaceId", "projectId", "roomId", "sourceRoomMessageId", "membershipId",
79
+ "assistantId", "inputMessageId", "role", "title",
80
+ ] as const;
81
+ const projectRoomValues = snapshotExactRecord(projectRoomValue, requiredKeys, ["responsibility"]);
82
+ const hasResponsibility = projectRoomValues !== undefined
83
+ && Object.prototype.hasOwnProperty.call(projectRoomValues, "responsibility");
84
+ if (!projectRoomValues
85
+ || requiredKeys.some((key) => typeof projectRoomValues[key] !== "string" || projectRoomValues[key].length === 0)
86
+ || (hasResponsibility && projectRoomValues.responsibility !== undefined
87
+ && (typeof projectRoomValues.responsibility !== "string" || projectRoomValues.responsibility.length === 0))
88
+ || (projectRoomValues.role !== "coordinator" && projectRoomValues.role !== "specialist")) {
89
+ throw new Error("Invalid trusted agent run context");
90
+ }
91
+ const parsedProjectRoom: ProjectRoomTrustedRunContext = {
92
+ tenantId: projectRoomValues.tenantId as string,
93
+ workspaceId: projectRoomValues.workspaceId as string,
94
+ projectId: projectRoomValues.projectId as string,
95
+ roomId: projectRoomValues.roomId as string,
96
+ sourceRoomMessageId: projectRoomValues.sourceRoomMessageId as string,
97
+ membershipId: projectRoomValues.membershipId as string,
98
+ assistantId: projectRoomValues.assistantId as string,
99
+ inputMessageId: projectRoomValues.inputMessageId as string,
100
+ role: projectRoomValues.role,
101
+ title: projectRoomValues.title as string,
102
+ };
103
+ if (hasResponsibility && typeof projectRoomValues.responsibility === "string") {
104
+ parsedProjectRoom.responsibility = projectRoomValues.responsibility;
105
+ }
106
+ return { projectRoom: parsedProjectRoom };
107
+ }
108
+
109
+ /**
110
+ * Strictly validates a queued execution mode read from durable storage.
111
+ *
112
+ * @param value - Untrusted database value.
113
+ * @returns The validated execution mode.
114
+ * @throws Error when the stored value is not supported.
115
+ */
116
+ export function parseQueuedExecutionMode(value: unknown): QueuedExecutionMode {
117
+ if (value !== "followup") throw new Error("Invalid queued execution mode");
118
+ return value;
119
+ }
@@ -102,6 +102,25 @@ export interface UpdateProjectRequest {
102
102
  kind?: ProjectKind;
103
103
  }
104
104
 
105
+ /** Error raised when generic project writes attempt to change capability Bundle references. */
106
+ export class InvalidProjectCapabilityBundleConfigError extends Error {
107
+ /** Stable machine-readable error code. */
108
+ readonly code = "INVALID_BUNDLE_CONFIG" as const;
109
+
110
+ /** Creates the reserved-config error returned by generic Project writes. */
111
+ constructor() {
112
+ super("Use the project capability-bundles endpoint to update capability bundle IDs");
113
+ this.name = "InvalidProjectCapabilityBundleConfigError";
114
+ }
115
+ }
116
+
117
+ /** Rejects capability Bundle references supplied through generic Project config writes. */
118
+ export function assertGenericProjectConfig(config: Record<string, unknown> | undefined): void {
119
+ if (config !== undefined && Object.prototype.hasOwnProperty.call(config, "capabilityBundleIds")) {
120
+ throw new InvalidProjectCapabilityBundleConfigError();
121
+ }
122
+ }
123
+
105
124
  /**
106
125
  * Filter options for listing projects within a workspace
107
126
  */
@@ -109,6 +128,16 @@ export interface ProjectFilter {
109
128
  kind?: ProjectKind;
110
129
  }
111
130
 
131
+ /** Atomic result of replacing a Project's capability Bundle IDs. */
132
+ export type UpdateProjectCapabilityBundlesResult =
133
+ | { status: "updated"; project: Project }
134
+ | { status: "project_not_found" }
135
+ | { status: "bundle_not_found" }
136
+ | { status: "bundle_conflict" };
137
+
138
+ /** Revision preconditions for the bundles reviewed before project assignment. */
139
+ export type ExpectedCapabilityBundleRevisions = Record<string, string>;
140
+
112
141
  /**
113
142
  * ProjectStore interface
114
143
  * Provides CRUD operations for project data
@@ -118,5 +147,9 @@ export interface ProjectStore {
118
147
  getProjectById(tenantId: string, id: string): Promise<Project | null>;
119
148
  createProject(tenantId: string, workspaceId: string, id: string, data: CreateProjectRequest): Promise<Project>;
120
149
  updateProject(tenantId: string, id: string, updates: UpdateProjectRequest): Promise<Project | null>;
150
+ /** Omitted expectedRevisions is reserved for internal maintenance callers. */
151
+ updateCapabilityBundleIds(tenantId: string, projectId: string, bundleIds: string[], expectedRevisions?: ExpectedCapabilityBundleRevisions): Promise<UpdateProjectCapabilityBundlesResult>;
121
152
  deleteProject(tenantId: string, id: string): Promise<boolean>;
153
+ /** Returns whether a tenant project references the given capability bundle. */
154
+ isCapabilityBundleReferenced(tenantId: string, bundleId: string): Promise<boolean>;
122
155
  }
@@ -0,0 +1,36 @@
1
+ import type { BindingMutablePatch, BindingRegistry, CreateBindingInput } from "../BindingProtocol";
2
+
3
+ describe("BindingProtocol mutation contract", () => {
4
+ it("accepts mutable fields and rejects identity fields at compile time", () => {
5
+ const mutable: BindingMutablePatch = {
6
+ agentId: "agent-2", threadId: "thread-2", workspaceId: "workspace-2",
7
+ projectId: "project-2", threadMode: "fixed", senderDisplayName: "Sender",
8
+ senderMetadata: { source: "test" }, enabled: false,
9
+ };
10
+ expect(mutable.enabled).toBe(false);
11
+
12
+ // @ts-expect-error channel is immutable after binding creation
13
+ const invalid: BindingMutablePatch = { channel: "room" };
14
+ expect(invalid).toBeDefined();
15
+ });
16
+
17
+ it("requires tenant scope for mutation methods", () => {
18
+ const update: BindingRegistry["update"] = async (...args) => {
19
+ expect(args).toHaveLength(3);
20
+ throw new Error("not invoked");
21
+ };
22
+ const remove: BindingRegistry["delete"] = async (...args) => {
23
+ expect(args).toHaveLength(2);
24
+ };
25
+ expect(update).toBeDefined();
26
+ expect(remove).toBeDefined();
27
+ });
28
+
29
+ it("allows trusted callers to choose the initial enabled state", () => {
30
+ const input: CreateBindingInput = {
31
+ channel: "room", channelInstallationId: "room-internal:tenant-a", tenantId: "tenant-a",
32
+ senderId: "room:room-1:membership-1", agentId: "agent-1", enabled: false,
33
+ };
34
+ expect(input.enabled).toBe(false);
35
+ });
36
+ });
@@ -0,0 +1,105 @@
1
+ import { runInNewContext } from "node:vm";
2
+ import { snapshotExactArray, snapshotExactRecord } from "../ExactDataSnapshot";
3
+
4
+ describe("exact data snapshots", () => {
5
+ it("canonicalizes cross-realm and hostile-prototype records without reading their prototype", () => {
6
+ const crossRealm = runInNewContext("({ id: 'one', value: 2 })");
7
+ let inheritedReads = 0;
8
+ const prototype = Object.create(null);
9
+ Object.defineProperty(prototype, "constructor", {
10
+ get: () => { inheritedReads += 1; throw new Error("must not run"); },
11
+ });
12
+ Object.defineProperty(prototype, "inherited", {
13
+ get: () => { inheritedReads += 1; throw new Error("must not run"); },
14
+ });
15
+ const hostilePrototype = Object.assign(Object.create(prototype), { id: "two", value: 3 });
16
+
17
+ expect(snapshotExactRecord(crossRealm, ["id", "value"])).toEqual({ id: "one", value: 2 });
18
+ expect(snapshotExactRecord(hostilePrototype, ["id", "value"])).toEqual({ id: "two", value: 3 });
19
+ expect(inheritedReads).toBe(0);
20
+ });
21
+
22
+ it.each([
23
+ ["accessor", Object.defineProperty({ id: "one" }, "value", { enumerable: true, get: () => 2 })],
24
+ ["extra", { id: "one", value: 2, extra: true }],
25
+ ["non-enumerable", Object.defineProperty({ id: "one" }, "value", { enumerable: false, value: 2 })],
26
+ ["symbol", Object.assign({ id: "one", value: 2 }, { [Symbol("extra")]: true })],
27
+ ])("rejects an own %s record property", (_name, value) => {
28
+ expect(snapshotExactRecord(value, ["id", "value"])).toBeUndefined();
29
+ });
30
+
31
+ it("canonicalizes cross-realm arrays and rejects non-dense or accessor-bearing arrays", () => {
32
+ expect(snapshotExactArray(runInNewContext("['one', 'two']"))).toEqual(["one", "two"]);
33
+ const accessor = ["one"];
34
+ Object.defineProperty(accessor, "0", { enumerable: true, get: () => "one" });
35
+ expect(snapshotExactArray(accessor)).toBeUndefined();
36
+ expect(snapshotExactArray(Object.assign(["one"], { extra: true }))).toBeUndefined();
37
+ expect(snapshotExactArray(new Array(1))).toBeUndefined();
38
+ });
39
+
40
+ it("rejects record accessors without reading polluted descriptor prototypes", () => {
41
+ const originalValue = Object.getOwnPropertyDescriptor(Object.prototype, "value");
42
+ const originalEnumerable = Object.getOwnPropertyDescriptor(Object.prototype, "enumerable");
43
+ let inheritedReads = 0;
44
+ const accessor = Object.defineProperty({ id: "one" }, "value", {
45
+ enumerable: true,
46
+ get: () => 2,
47
+ });
48
+ try {
49
+ Object.defineProperty(Object.prototype, "value", {
50
+ configurable: true,
51
+ get: () => {
52
+ inheritedReads += 1;
53
+ return "forged";
54
+ },
55
+ });
56
+ Object.defineProperty(Object.prototype, "enumerable", Object.assign(Object.create(null), {
57
+ configurable: true,
58
+ get: () => {
59
+ inheritedReads += 1;
60
+ return true;
61
+ },
62
+ }));
63
+
64
+ expect(snapshotExactRecord(accessor, ["id", "value"])).toBeUndefined();
65
+ expect(inheritedReads).toBe(0);
66
+ } finally {
67
+ if (originalValue) Object.defineProperty(Object.prototype, "value", originalValue);
68
+ else delete (Object.prototype as { value?: unknown }).value;
69
+ if (originalEnumerable) Object.defineProperty(Object.prototype, "enumerable", originalEnumerable);
70
+ else delete (Object.prototype as { enumerable?: unknown }).enumerable;
71
+ }
72
+ });
73
+
74
+ it("rejects array accessors without reading polluted descriptor prototypes", () => {
75
+ const originalValue = Object.getOwnPropertyDescriptor(Object.prototype, "value");
76
+ const originalEnumerable = Object.getOwnPropertyDescriptor(Object.prototype, "enumerable");
77
+ let inheritedReads = 0;
78
+ const accessor = ["one"];
79
+ Object.defineProperty(accessor, "0", { enumerable: true, get: () => "one" });
80
+ try {
81
+ Object.defineProperty(Object.prototype, "value", {
82
+ configurable: true,
83
+ get: () => {
84
+ inheritedReads += 1;
85
+ return "forged";
86
+ },
87
+ });
88
+ Object.defineProperty(Object.prototype, "enumerable", Object.assign(Object.create(null), {
89
+ configurable: true,
90
+ get: () => {
91
+ inheritedReads += 1;
92
+ return true;
93
+ },
94
+ }));
95
+
96
+ expect(snapshotExactArray(accessor)).toBeUndefined();
97
+ expect(inheritedReads).toBe(0);
98
+ } finally {
99
+ if (originalValue) Object.defineProperty(Object.prototype, "value", originalValue);
100
+ else delete (Object.prototype as { value?: unknown }).value;
101
+ if (originalEnumerable) Object.defineProperty(Object.prototype, "enumerable", originalEnumerable);
102
+ else delete (Object.prototype as { enumerable?: unknown }).enumerable;
103
+ }
104
+ });
105
+ });
@@ -0,0 +1,48 @@
1
+ import type {
2
+ ProjectBotMembership,
3
+ ProjectRoomMessage,
4
+ ProjectRoomMessageCursor,
5
+ } from "../ProjectRoomProtocol";
6
+
7
+ describe("Project Room protocol types", () => {
8
+ it("models bot membership, system messages, and message cursors", () => {
9
+ const bot: ProjectBotMembership = {
10
+ id: "bm-1",
11
+ tenantId: "t-1",
12
+ workspaceId: "w-1",
13
+ projectId: "p-1",
14
+ roomId: "r-1",
15
+ assistantId: "a-1",
16
+ role: "coordinator",
17
+ title: "Lead",
18
+ mentionName: "lead",
19
+ status: "paused",
20
+ roomThreadId: "th-1",
21
+ joinedAt: new Date(0),
22
+ updatedAt: new Date(0),
23
+ };
24
+ const message: ProjectRoomMessage = {
25
+ id: "m-1",
26
+ tenantId: "t-1",
27
+ workspaceId: "w-1",
28
+ projectId: "p-1",
29
+ roomId: "r-1",
30
+ author: { type: "system" },
31
+ content: { type: "text", text: "Room ready" },
32
+ mentions: [],
33
+ source: "system",
34
+ idempotencyKey: "room-ready:p-1",
35
+ createdAt: new Date(0),
36
+ };
37
+ const cursor: ProjectRoomMessageCursor = {
38
+ createdAt: message.createdAt,
39
+ id: message.id,
40
+ };
41
+
42
+ expect([bot.status, message.source, cursor.id]).toEqual([
43
+ "paused",
44
+ "system",
45
+ "m-1",
46
+ ]);
47
+ });
48
+ });