diff --git a/packages/features/src/contract/live.ts b/packages/features/src/contract/live.ts index 1ad9655a25..6ddafdd154 100644 --- a/packages/features/src/contract/live.ts +++ b/packages/features/src/contract/live.ts @@ -16,7 +16,8 @@ * chat, named by `?workspaceId=`, as the statements the window's chat read * is built from, once the account has joined it; and the account's * workspaces, as the statements of the workspaces and of the GitHub - * installations connected to them. Each + * installations connected to them; and one workspace's people, named by + * `?workspaceId=`, as its members and who its work names but left. Each * asked with `?poll=true` answers each statement's rows inline instead, for * a reader polling while Realtime fails or is off; with `&after=` too, the * poll is held until the rows move past it, or answered unchanged. Each @@ -45,7 +46,7 @@ import { } from "../live-wire.js"; import { HOSTED_SERVICE_PATH } from "../service-paths.js"; import { taskListLiveAnswerSchema } from "../task-list-live.js"; -import { workspacesLiveAnswerSchema } from "../workspace-live.js"; +import { workspaceMembersLiveAnswerSchema, workspacesLiveAnswerSchema } from "../workspace-live.js"; import { hostedProcedure } from "./base.js"; /** Whether the reader asks for the rows inline, polling while Realtime fails it (`LIVE_POLL`). */ @@ -114,6 +115,14 @@ const liveChatSchema = EffectSchema.Struct({ [LIVE_USER_QUERY]: userField, }); +/** The workspace a live read is followed for; one that names no workspace of the account's is answered as not found, so it is read as text here. */ +const liveWorkspaceSchema = EffectSchema.Struct({ + workspaceId: EffectSchema.String, + [LIVE_POLL.QUERY]: pollField, + [LIVE_POLL.AFTER]: afterField, + [LIVE_USER_QUERY]: userField, +}); + export const liveContract = { inbox: hostedProcedure .meta(openapi({ method: HTTP_METHOD.GET, path: HOSTED_SERVICE_PATH.LIVE_INBOX })) @@ -188,4 +197,8 @@ export const liveContract = { .meta(openapi({ method: HTTP_METHOD.GET, path: HOSTED_SERVICE_PATH.LIVE_WORKSPACES })) .input(liveReadSchema) .output(EffectSchema.flip(workspacesLiveAnswerSchema)), + workspaceMembers: hostedProcedure + .meta(openapi({ method: HTTP_METHOD.GET, path: HOSTED_SERVICE_PATH.LIVE_WORKSPACE_MEMBERS })) + .input(liveWorkspaceSchema) + .output(EffectSchema.flip(workspaceMembersLiveAnswerSchema)), }; diff --git a/packages/features/src/service-paths.ts b/packages/features/src/service-paths.ts index f0b9c32359..ed2140fd2b 100644 --- a/packages/features/src/service-paths.ts +++ b/packages/features/src/service-paths.ts @@ -113,6 +113,8 @@ export const HOSTED_SERVICE_PATH = { LIVE_AUTOMATION_RUNS: "/api/live/automation-runs", /** The account's workspaces, sealed for following over Neon Realtime (GET): the workspaces and their installations, or their rows inline where polled. */ LIVE_WORKSPACES: "/api/live/workspaces", + /** One workspace's people under `?workspaceId=`, sealed for following over Neon Realtime (GET): its members and who its work names but left, or `not-found`, or their rows inline where polled. */ + LIVE_WORKSPACE_MEMBERS: "/api/live/workspace-members", /** * A workspace's agents, on a task or with none, newest started first and * the archived ones left out unless the query asks for them, narrowed to diff --git a/packages/features/src/workspace-live.ts b/packages/features/src/workspace-live.ts index 88f253e4ee..6b54c71f64 100644 --- a/packages/features/src/workspace-live.ts +++ b/packages/features/src/workspace-live.ts @@ -2,10 +2,16 @@ import { Schema as EffectSchema } from "effect"; import { pinnedPictureUrl } from "./account-picture.js"; import { sealedLiveQuerySchema } from "./live-wire.js"; import { countedNumber } from "./service-wire.js"; -import { roleOf, type Workspace } from "./workspace-wire.js"; +import { + roleOf, + WORKSPACE_ROLE, + type Workspace, + type WorkspaceMembersAnswer, + type WorkspaceRole, +} from "./workspace-wire.js"; /** - * workspace-live.ts -- the account's workspaces as the desktop follows them over Neon Realtime: the sealed statements the service answers, and the rows they deliver. + * workspace-live.ts -- the account's workspaces, and one workspace's people, as the desktop follows them over Neon Realtime: the sealed statements the service answers, and the rows they deliver. * * Neon's guide is followed as `live-wire.ts` follows it: the service builds * each statement with the account's id in it and seals it, and the desktop @@ -119,3 +125,80 @@ export function workspacesOfLiveRows( ) .map((row) => workspaceOfLiveRow(row, installations.get(row.id) ?? [])); } + +/** + * One workspace's people sealed for following, read for the account asking: + * the address, and two statements, its members and who its work names but + * left. The service answers `not-found` for a workspace the account is not + * a member of; asked to poll, or where Realtime cannot seal them, it answers + * each statement's rows inline instead (`LIVE_POLL`). + */ +export const workspaceMembersLiveAnswerSchema = EffectSchema.Struct({ + url: EffectSchema.String, + queries: EffectSchema.Struct({ + members: sealedLiveQuerySchema, + former: sealedLiveQuerySchema, + }), +}); + +/** A workspace's people as their rows deliver them: each sign-in picture as the account holds it, null for none. */ +export const workspaceMembersLiveRowsSchema = EffectSchema.Struct({ + members: EffectSchema.Array( + EffectSchema.Struct({ + userId: EffectSchema.String, + name: EffectSchema.String, + email: EffectSchema.String, + /** The plugin's role text, which may list several roles comma-separated. */ + role: EffectSchema.String, + image: EffectSchema.NullOr(EffectSchema.String), + }), + ), + former: EffectSchema.Array( + EffectSchema.Struct({ + userId: EffectSchema.String, + name: EffectSchema.String, + image: EffectSchema.NullOr(EffectSchema.String), + }), + ), +}); + +/** The order roles are listed in: owners, then admins, then members. */ +const ROLE_ORDER: readonly WorkspaceRole[] = [ + WORKSPACE_ROLE.OWNER, + WORKSPACE_ROLE.ADMIN, + WORKSPACE_ROLE.MEMBER, +]; + +/** By name, then by id, as the held read orders people. */ +function byName( + a: { readonly name: string; readonly userId: string }, + b: { readonly name: string; readonly userId: string }, +): number { + return byText(a.name, b.name) || byText(a.userId, b.userId); +} + +/** A picture the renderer may draw, under the key the wire carries it by; nothing for any other. */ +function pictureOf(image: string | null): { readonly pictureUrl?: string } { + const pictureUrl = pinnedPictureUrl(image); + return pictureUrl === undefined ? {} : { pictureUrl }; +} + +/** + * A workspace's people as the held read answers them: the members owners + * first, then admins, then members, each by name, and who left by name, + * absent where nobody did. + */ +export function workspaceMembersOfLiveRows( + rows: typeof workspaceMembersLiveRowsSchema.Type, +): WorkspaceMembersAnswer { + const members = rows.members + .toSorted(byName) + .map(({ image, role, ...member }) => ({ ...member, role: roleOf(role), ...pictureOf(image) })); + const former = rows.former + .toSorted(byName) + .map(({ image, ...person }) => ({ ...person, ...pictureOf(image) })); + return { + members: ROLE_ORDER.flatMap((role) => members.filter((one) => one.role === role)), + ...(former.length === 0 ? undefined : { former }), + }; +} diff --git a/packages/features/src/workspace-wire.ts b/packages/features/src/workspace-wire.ts index b00deb8338..255198c909 100644 --- a/packages/features/src/workspace-wire.ts +++ b/packages/features/src/workspace-wire.ts @@ -380,6 +380,8 @@ export const workspaceMembersAnswerSchema = EffectSchema.Struct({ former: EffectSchema.optionalKey(EffectSchema.Array(formerMemberSchema)), }); +export type WorkspaceMembersAnswer = typeof workspaceMembersAnswerSchema.Type; + /** The query a members read names its workspace under. */ export const WORKSPACE_MEMBERS_QUERY = { WORKSPACE_ID: "workspaceId" } as const; diff --git a/packages/service/src/accounts/workspace-members.test.ts b/packages/service/src/accounts/workspace-members.test.ts new file mode 100644 index 0000000000..5824834b8e --- /dev/null +++ b/packages/service/src/accounts/workspace-members.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { it } from "@effect/vitest"; +import { Database } from "@luke/database/core/query"; +import { createTask } from "@luke/database/models/task-store"; +import { user } from "@luke/database/schemas/auth"; +import { + getTestDB, + joinWorkspace, + leaveWorkspace, + openWorkspace, + WORKSPACE_ROLE, +} from "@luke/database/testing"; +import { + workspaceMembersLiveRowsSchema, + workspaceMembersOfLiveRows, +} from "@luke/features/workspace-live"; +import { Effect, Option, Schema } from "effect"; +import { SqlClient } from "effect/sql"; +import { + listFormerMembers, + listMembers, + workspaceMembersLiveStatements, +} from "./workspace-members.js"; + +/** + * A workspace's people as the live statements deliver them: the members and + * who the workspace's work names but left, exactly as the held reads answer + * them, to a member, and nothing at all to an outsider or to a member once + * they leave. + * + * Synthetic accounts throughout, over PGlite. + */ + +/** A new account under the name and sign-in picture given. */ +const openUser = (name: string, image: string | null = null) => + Effect.gen(function* () { + const userId = `user-${randomUUID()}`; + yield* Database.use((db) => + db.insert(user).values({ id: userId, name, email: `${userId}@luke.test`, image }), + ); + return userId; + }); + +/** The workspace's people as the desktop builds them from the live rows, run as Realtime runs them, for `readerId`. */ +const livePeopleOf = (readerId: string, organizationId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const statements = Option.getOrThrow(workspaceMembersLiveStatements(readerId, organizationId)); + const rows = yield* Effect.all({ + members: sql.unsafe(statements.members.sql, [...statements.members.params]), + former: sql.unsafe(statements.former.sql, [...statements.former.params]), + }); + return workspaceMembersOfLiveRows( + yield* Schema.decodeUnknownEffect(workspaceMembersLiveRowsSchema)(rows), + ); + }); + +it.layer(getTestDB())("a workspace's people, live", (it) => { + it.effect( + "the live statements deliver the members and the former members the held reads answer to a member, and nothing to an outsider or to a member who left", + () => + Effect.gen(function* () { + const owner = yield* openUser("Owner", "https://avatars.githubusercontent.com/u/1"); + const admin = yield* openUser("Admin"); + const teammate = yield* openUser("Bea"); + const leaver = yield* openUser("Leaver", "https://example.test/not-drawn.png"); + const outsider = yield* openUser("Outsider"); + const workspaceId = yield* openWorkspace(owner); + yield* openWorkspace(outsider); + yield* joinWorkspace(workspaceId, admin, WORKSPACE_ROLE.ADMIN); + yield* joinWorkspace(workspaceId, teammate); + yield* joinWorkspace(workspaceId, leaver); + yield* createTask(leaver, { organizationId: workspaceId, name: "Left behind" }); + yield* leaveWorkspace(workspaceId, leaver); + + const members = yield* listMembers(workspaceId); + const former = yield* listFormerMembers(workspaceId); + assert.deepEqual( + members.map((one) => one.role), + [WORKSPACE_ROLE.OWNER, WORKSPACE_ROLE.ADMIN, WORKSPACE_ROLE.MEMBER], + ); + assert.deepEqual( + former.map((one) => one.userId), + [leaver], + ); + for (const reader of [owner, admin, teammate]) { + assert.deepEqual(yield* livePeopleOf(reader, workspaceId), { members, former }); + } + const nobody = { members: [] }; + assert.deepEqual(yield* livePeopleOf(outsider, workspaceId), nobody); + assert.deepEqual(yield* livePeopleOf(leaver, workspaceId), nobody); + }), + ); +}); diff --git a/packages/service/src/accounts/workspace-members.ts b/packages/service/src/accounts/workspace-members.ts index 1ec4ab77ac..a7af3baab6 100644 --- a/packages/service/src/accounts/workspace-members.ts +++ b/packages/service/src/accounts/workspace-members.ts @@ -1,5 +1,13 @@ -import { Database } from "@luke/database/core/query"; -import type { WorkspaceStoreEffect } from "@luke/database/models/workspace-store"; +import { + type LiveStatement, + liveSelection, + liveStatementOf, +} from "@luke/database/core/live-statement"; +import { Database, queryBuilder } from "@luke/database/core/query"; +import { + type WorkspaceStoreEffect, + workspaceReachableBy, +} from "@luke/database/models/workspace-store"; import { member, user } from "@luke/database/schemas/auth"; import { automation } from "@luke/database/schemas/automation"; import { codingAgent } from "@luke/database/schemas/coding-agent"; @@ -10,7 +18,7 @@ import { pinnedPictureUrl } from "@luke/features/account-picture"; import { AUTOMATION_SCOPE } from "@luke/features/automation-wire"; import type { FormerMember, WorkspaceMember, WorkspaceRole } from "@luke/features/workspace-wire"; import { roleOf, WORKSPACE_ROLE } from "@luke/features/workspace-wire"; -import { and, asc, eq, inArray, isNotNull, notInArray, or } from "drizzle-orm"; +import { and, asc, eq, inArray, isNotNull, notInArray, or, type SQL } from "drizzle-orm"; import { Effect, Option, Schema } from "effect"; import { SqlSchema } from "effect/sql"; @@ -69,6 +77,74 @@ const findMembers = SqlSchema.findAll({ ), }); +/** + * The condition that the account a statement reads is named by the + * workspace's work and is no longer a member of it: it created one of its + * tasks, started one of its agents, made one of its workspace automations, + * saved a version of one of its tasks, or answered one of its Inbox items. + */ +function formerOf(organizationId: string): SQL | undefined { + const tasks = queryBuilder + .select({ id: task.id }) + .from(task) + .where(eq(task.organizationId, organizationId)); + return and( + or( + inArray( + user.id, + queryBuilder + .select({ id: task.userId }) + .from(task) + .where(eq(task.organizationId, organizationId)), + ), + inArray( + user.id, + queryBuilder + .select({ id: codingAgent.userId }) + .from(codingAgent) + .where(eq(codingAgent.organizationId, organizationId)), + ), + // Note that only the workspace's own automations count, since a personal one is read by its maker alone. + inArray( + user.id, + queryBuilder + .select({ id: automation.userId }) + .from(automation) + .where( + and( + eq(automation.organizationId, organizationId), + eq(automation.scope, AUTOMATION_SCOPE.WORKSPACE), + ), + ), + ), + inArray( + user.id, + queryBuilder + .select({ id: planRevision.userId }) + .from(planRevision) + .where(and(inArray(planRevision.taskId, tasks), isNotNull(planRevision.userId))), + ), + inArray( + user.id, + queryBuilder + .select({ id: inboxItem.answeredBy }) + .from(inboxItem) + // Note that an item is the workspace's by its own column, since an agent with no task holds items too. + .where( + and(eq(inboxItem.organizationId, organizationId), isNotNull(inboxItem.answeredBy)), + ), + ), + ), + notInArray( + user.id, + queryBuilder + .select({ id: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)), + ), + ); +} + const findFormer = SqlSchema.findAll({ Request: Schema.String, Result: Schema.Struct({ @@ -77,76 +153,13 @@ const findFormer = SqlSchema.findAll({ image: Schema.NullOr(Schema.String), }), execute: (organizationId) => - Database.use((db) => { - const tasks = db - .select({ id: task.id }) - .from(task) - .where(eq(task.organizationId, organizationId)); - return db + Database.use((db) => + db .select({ userId: user.id, name: user.name, image: user.image }) .from(user) - .where( - and( - or( - inArray( - user.id, - db - .select({ id: task.userId }) - .from(task) - .where(eq(task.organizationId, organizationId)), - ), - inArray( - user.id, - db - .select({ id: codingAgent.userId }) - .from(codingAgent) - .where(eq(codingAgent.organizationId, organizationId)), - ), - // Note that only the workspace's own automations count, since a personal one is read by its maker alone. - inArray( - user.id, - db - .select({ id: automation.userId }) - .from(automation) - .where( - and( - eq(automation.organizationId, organizationId), - eq(automation.scope, AUTOMATION_SCOPE.WORKSPACE), - ), - ), - ), - inArray( - user.id, - db - .select({ id: planRevision.userId }) - .from(planRevision) - .where(and(inArray(planRevision.taskId, tasks), isNotNull(planRevision.userId))), - ), - inArray( - user.id, - db - .select({ id: inboxItem.answeredBy }) - .from(inboxItem) - // Note that an item is the workspace's by its own column, since an agent with no task holds items too. - .where( - and( - eq(inboxItem.organizationId, organizationId), - isNotNull(inboxItem.answeredBy), - ), - ), - ), - ), - notInArray( - user.id, - db - .select({ id: member.userId }) - .from(member) - .where(eq(member.organizationId, organizationId)), - ), - ), - ) - .orderBy(asc(user.name), asc(user.id)); - }), + .where(formerOf(organizationId)) + .orderBy(asc(user.name), asc(user.id)), + ), }); const findMemberId = SqlSchema.findOneOption({ @@ -198,3 +211,39 @@ export function memberIdOf( Option.map((row) => row.id), ); } + +/** The statements a workspace's people are followed by live: its members, and who its work names but left. */ +export type WorkspaceMembersLiveStatements = Readonly>; + +/** + * A workspace's people written for Neon Realtime, read for `readerId`: the + * same rows and columns as `listMembers` and `listFormerMembers`, each under + * the key `live-wire.ts` reads it by, and none at all once the reader is no + * longer a member, so a subscription stays bound to the rule its endpoint + * checked. The order is the reader's to keep, since Realtime keeps none. + * None where a statement names a value Realtime cannot carry. + */ +export function workspaceMembersLiveStatements( + readerId: string, + organizationId: string, +): Option.Option { + const reached = workspaceReachableBy(readerId, organizationId); + const members = queryBuilder + .select( + liveSelection({ + userId: user.id, + name: user.name, + email: user.email, + role: member.role, + image: user.image, + }), + ) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .where(and(eq(member.organizationId, organizationId), reached)); + const former = queryBuilder + .select(liveSelection({ userId: user.id, name: user.name, image: user.image })) + .from(user) + .where(and(formerOf(organizationId), reached)); + return Option.all({ members: liveStatementOf(members), former: liveStatementOf(former) }); +} diff --git a/packages/service/src/live/live-procedures.test.ts b/packages/service/src/live/live-procedures.test.ts index aa85527e35..187f77a9cb 100644 --- a/packages/service/src/live/live-procedures.test.ts +++ b/packages/service/src/live/live-procedures.test.ts @@ -35,7 +35,10 @@ import { } from "@luke/features/live-wire"; import { PAGE_SHOWER } from "@luke/features/page-wire"; import { taskListLiveAnswerSchema } from "@luke/features/task-list-live"; -import { workspacesLiveAnswerSchema } from "@luke/features/workspace-live"; +import { + workspaceMembersLiveAnswerSchema, + workspacesLiveAnswerSchema, +} from "@luke/features/workspace-live"; import { EXPERIMENT } from "@luke/settings"; import { TURN_ORIGIN, type WireBoundaryInput, type WireRecord } from "@luke/wire"; import type { StoredUIMessage } from "@luke/wire/ui-messages"; @@ -1400,3 +1403,38 @@ it.effect( ); }), ); + +it.layer(getTestDB())("a workspace's people's live read", (it) => { + it.effect( + "a workspace's people are sealed as its members and former members for a member, and not found for an outsider", + () => + Effect.gen(function* () { + const db = yield* Database; + const [owner, outsider] = [`user-${randomUUID()}`, `user-${randomUUID()}`]; + for (const id of [owner, outsider]) { + yield* db.insert(user).values({ id, name: "Test User", email: `${id}@luke.test` }); + } + const workspaceId = yield* openWorkspace(owner); + const path = `/api/live/workspace-members?workspaceId=${workspaceId}`; + + const sealed = yield* ask({ bearer: owner, path, sealing: Option.some(SCRIPTED) }); + assert.equal(sealed.status, HOSTED_HTTP_STATUS.OK); + const answer = Schema.decodeUnknownSync(workspaceMembersLiveAnswerSchema)(sealed.body); + assert.deepEqual(Object.keys(answer.queries).toSorted(), ["former", "members"]); + for (const query of Object.values(answer.queries)) { + assert.ok(query.capability.includes(`"${owner}"`), "read for the bearer"); + assert.ok(query.capability.includes(`"${workspaceId}"`), "read of the workspace"); + } + assert.deepEqual(yield* ask({ bearer: outsider, path, sealing: Option.some(SCRIPTED) }), { + status: HOSTED_HTTP_STATUS.NOT_FOUND, + body: { error: HOSTED_API_ERROR.NOT_FOUND }, + }); + // A read started for another account is signed out, before the workspace is looked at. + assert.equal( + (yield* ask({ bearer: owner, forUser: outsider, path, sealing: Option.some(SCRIPTED) })) + .status, + HOSTED_HTTP_STATUS.UNAUTHORIZED, + ); + }), + ); +}); diff --git a/packages/service/src/live/live-procedures.ts b/packages/service/src/live/live-procedures.ts index a97ad7a9e1..574988a969 100644 --- a/packages/service/src/live/live-procedures.ts +++ b/packages/service/src/live/live-procedures.ts @@ -32,7 +32,10 @@ * * `GET /api/live/workspaces` answers the account's workspaces the same way, * as two statements: the workspaces, and the GitHub installations connected - * to them. + * to them. `GET /api/live/workspace-members?workspaceId=` answers one + * workspace's people as two, its members and who its work names but left, + * after the account is read as a member of it, so a workspace it is not in + * is not found, as its held read answers. * * `GET /api/live/agent-transcript?id=` answers what one coding agent's * transcript is read from, or one of its workers' with `&call=` naming the @@ -63,7 +66,7 @@ import { readCodingAgent } from "@luke/database/models/coding-agent-store"; import { childConversationId } from "@luke/database/models/ids"; import { macCommandsLiveStatement } from "@luke/database/models/mac-command-store"; import { readTask } from "@luke/database/models/task-store"; -import { workspacesLiveStatements } from "@luke/database/models/workspace-store"; +import { isWorkspaceMember, workspacesLiveStatements } from "@luke/database/models/workspace-store"; import { CODING_AGENT_STATUS, codingAgentCallIdSchema } from "@luke/features/coding-agent-wire"; import { LIVE_POLL, @@ -75,6 +78,7 @@ import { macCommandKindsRead } from "@luke/features/mac-command-wire"; import { readEither } from "@luke/wire/effect"; import { Duration, Effect, Option, Result } from "effect"; import { SqlClient } from "effect/sql"; +import { workspaceMembersLiveStatements } from "../accounts/workspace-members.js"; import { reachedAgentById } from "../agents/agent-reach.js"; import { joinedMember } from "../agents/coder-host/chat-page.js"; import { @@ -393,5 +397,15 @@ export function liveProcedures(seams: LiveProcedureSeams) { if (Option.isNone(statements)) return yield* Effect.fail(HOSTED_REFUSAL.UNAVAILABLE); return yield* sealedParts(statements.value, input); }), + workspaceMembers: signedIn.workspaceMembers.effect(function* ({ input, context }) { + yield* sameUser(input, context.userId); + const member = yield* hostedStoreOrUnavailable( + isWorkspaceMember(context.userId, input.workspaceId), + ); + if (!member) return yield* Effect.fail(HOSTED_REFUSAL.NOT_FOUND); + const statements = workspaceMembersLiveStatements(context.userId, input.workspaceId); + if (Option.isNone(statements)) return yield* Effect.fail(HOSTED_REFUSAL.UNAVAILABLE); + return yield* sealedParts(statements.value, input); + }), }; } diff --git a/packages/service/src/live/live-statements.test.ts b/packages/service/src/live/live-statements.test.ts index fe996d720f..d47d7b6d8b 100644 --- a/packages/service/src/live/live-statements.test.ts +++ b/packages/service/src/live/live-statements.test.ts @@ -10,6 +10,7 @@ import { getTestDB, mutableFunctionsOf } from "@luke/database/testing"; import { MEMBER_KIND } from "@luke/features/chat-wire"; import { MAC_COMMAND_KIND } from "@luke/features/mac-command-wire"; import { ConfigProvider, Effect, Layer, Option } from "effect"; +import { workspaceMembersLiveStatements } from "../accounts/workspace-members.js"; import { agentFollowedBy, taskFollowedBy, @@ -114,6 +115,11 @@ function liveStatements() { query, ]), ), + ...Object.fromEntries( + Object.entries(Option.getOrThrow(workspaceMembersLiveStatements(USER_ID, randomUUID()))).map( + ([part, query]) => [`workspace-members.${part}`, query], + ), + ), }; }