diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts index c6a93f9ca..e648b45f2 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts @@ -28,6 +28,7 @@ import { import { githubIssuesCollector } from './brain-collectors/github-issues'; import { discordPublicChannelsCollector } from './brain-collectors/discord-public-channels'; import { granolaMeetingsCollector } from './brain-collectors/granola-meetings'; +import { linearIssuesCollector } from './brain-collectors/linear-issues'; import { notionPagesCollector, notionUsersCollector, @@ -379,4 +380,5 @@ const BRAIN_COLLECTORS: BrainCollector[] = [ notionPagesCollector, granolaMeetingsCollector, githubIssuesCollector, + linearIssuesCollector, ]; diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts new file mode 100644 index 000000000..4303eb2cf --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts @@ -0,0 +1,28 @@ +import { BRAIN_COLLECTOR_IDS } from '@roomote/types'; + +import { + backfillBrainLinearIssuesStep, + collectBrainLinearIssues, + isBrainSourceAvailable, +} from '@roomote/sdk/server'; + +import type { BrainCollector } from './contracts'; + +/** + * Linear issues are durable product context. The SDK collector reuses the + * deployment OAuth connection, keeps comments bounded inside each issue page, + * and performs periodic complete visibility sweeps before retiring pages. + */ +export const linearIssuesCollector: BrainCollector = { + id: BRAIN_COLLECTOR_IDS.linearIssues, + displayName: 'Linear issues', + async isEnabled() { + return isBrainSourceAvailable('linear'); + }, + async collect({ now, limit }) { + return collectBrainLinearIssues({ now, limit }); + }, + async backfill({ cursor, limit }) { + return backfillBrainLinearIssuesStep({ cursor, limit }); + }, +}; diff --git a/apps/docs/integrations/linear.mdx b/apps/docs/integrations/linear.mdx index 237d70b90..889bc4ef4 100644 --- a/apps/docs/integrations/linear.mdx +++ b/apps/docs/integrations/linear.mdx @@ -6,7 +6,8 @@ icon: 'https://api.iconify.design/simple-icons:linear.svg?color=currentColor' The Linear integration lets Roomote receive work from Linear, post progress back to the issue or agent session, and keep the full run available in the Roomote -task view. +task view. When the Brain is configured, Roomote also indexes issues from the +connected workspace so agents can recall product context before opening Linear. Linear is optional during onboarding. Connect it when your team wants Roomote to work from issues that already have product context, acceptance criteria, @@ -68,6 +69,19 @@ Roomote can post status, plan updates, and final responses back to Linear. The Roomote task view remains the best place to inspect logs, diffs, artifacts, and previews. +## Brain context + +The Brain keeps one durable page per visible Linear issue, including its current +workflow state, team, project, priority, labels, assignee, description, and a +bounded set of recent comments. Collection uses the same workspace connection +configured above; it does not require another Linear token. + +Roomote refreshes changed issues incrementally and periodically checks the full +visible issue set. Archived issues remain available as historical context. If an +issue is deleted or the connected app can no longer see it, Roomote removes its +page only after a complete visibility check, avoiding deletion from a partial or +failed API response. + Put acceptance criteria and relevant repository links directly in the Linear issue before starting agent work. diff --git a/apps/web/src/trpc/commands/brain/summarize-sources.test.ts b/apps/web/src/trpc/commands/brain/summarize-sources.test.ts index da1274db7..40d7d4f24 100644 --- a/apps/web/src/trpc/commands/brain/summarize-sources.test.ts +++ b/apps/web/src/trpc/commands/brain/summarize-sources.test.ts @@ -44,6 +44,7 @@ const ALL_CONNECTED = { notion: true, granola: true, rippling: false, + linear: true, } as const; function summarize( @@ -176,9 +177,10 @@ describe('summarizeSources', () => { }); it('reports a disconnected requirement over any sync state', () => { - const sources = summarize([]); + const sources = summarize([], { linear: false }); expect(sourceOf(sources, 'rippling-workers').status).toBe('not_connected'); + expect(sourceOf(sources, 'linear-issues').status).toBe('not_connected'); expect(sourceOf(sources, 'task-memories').status).toBe('ingesting'); expect(sourceOf(sources, 'task-memories').lastSyncedAt).toEqual( new Date('2026-08-20T18:50:00Z'), diff --git a/packages/linear/src/__tests__/linear-client-brain.test.ts b/packages/linear/src/__tests__/linear-client-brain.test.ts new file mode 100644 index 000000000..c75d1a669 --- /dev/null +++ b/packages/linear/src/__tests__/linear-client-brain.test.ts @@ -0,0 +1,102 @@ +const rawRequest = vi.hoisted(() => vi.fn()); + +vi.mock('@linear/sdk', () => ({ + AgentActivitySignal: { Select: 'select', Auth: 'auth' }, + LinearClient: class { + client = { rawRequest }; + }, +})); + +import { createLinearClient } from '../linear-client'; + +describe('LinearClient.listIssuesForBrain', () => { + it('normalizes a bounded issue page and comment authors', async () => { + rawRequest.mockResolvedValue({ + data: { + issues: { + nodes: [ + { + id: 'issue-1', + identifier: 'ENG-1', + title: 'Collect Linear issues', + description: null, + url: 'https://linear.app/acme/issue/ENG-1', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + labels: { nodes: [{ name: 'brain' }] }, + comments: { + nodes: [ + { + id: 'comment-1', + body: 'Use the existing OAuth connection.', + createdAt: '2026-08-01T01:00:00.000Z', + updatedAt: '2026-08-01T01:00:00.000Z', + externalUser: { name: 'External author' }, + }, + ], + }, + }, + ], + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }, + }, + }); + + const result = await createLinearClient('token').listIssuesForBrain({ + first: 500, + after: 'cursor-1', + updatedAfter: '2026-08-01T00:00:00.000Z', + updatedBefore: '2026-08-03T00:00:00.000Z', + }); + + expect(rawRequest).toHaveBeenCalledWith( + expect.stringContaining('comments(last: 20'), + { + first: 100, + after: 'cursor-1', + filter: { + updatedAt: { + gte: '2026-08-01T00:00:00.000Z', + lte: '2026-08-03T00:00:00.000Z', + }, + }, + }, + ); + expect(result).toEqual({ + issues: [ + expect.objectContaining({ + id: 'issue-1', + labels: ['brain'], + comments: [expect.objectContaining({ author: 'External author' })], + }), + ], + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }); + }); + + it('supports stable creation-time pagination for visibility censuses', async () => { + rawRequest.mockResolvedValue({ + data: { + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + await createLinearClient('token').listIssuesForBrain({ + first: 50, + orderBy: 'createdAt', + createdBefore: '2026-08-20T12:00:00.000Z', + }); + + expect(rawRequest).toHaveBeenCalledWith( + expect.stringContaining('orderBy: createdAt'), + expect.objectContaining({ + filter: { + createdAt: { lte: '2026-08-20T12:00:00.000Z' }, + }, + }), + ); + }); +}); diff --git a/packages/linear/src/index.ts b/packages/linear/src/index.ts index 407b22e34..fc2beaab9 100644 --- a/packages/linear/src/index.ts +++ b/packages/linear/src/index.ts @@ -4,6 +4,8 @@ export type { AgentSessionEventAction, HumanToAgentSignal, LinearIssue, + LinearBrainIssue, + LinearBrainIssuePage, LinearComment, LinearUser, AgentGuidance, diff --git a/packages/linear/src/linear-client.ts b/packages/linear/src/linear-client.ts index 31923eddf..271a60817 100644 --- a/packages/linear/src/linear-client.ts +++ b/packages/linear/src/linear-client.ts @@ -6,6 +6,7 @@ import type { AgentSessionPlanStep, AgentSessionUpdateResult, LinearComment, + LinearBrainIssuePage, LinearOrganization, LinearViewer, } from './types'; @@ -46,6 +47,171 @@ export class LinearClient { }; } + /** + * Read one bounded issue page for durable Brain ingestion. Comments are + * fetched in the same GraphQL request so collection does not create an N+1 + * request pattern. + */ + async listIssuesForBrain(input: { + first: number; + after?: string | null; + orderBy?: 'createdAt' | 'updatedAt'; + createdBefore?: string | null; + updatedAfter?: string | null; + updatedBefore?: string | null; + }): Promise { + const query = ` + query BrainIssues( + $first: Int! + $after: String + $filter: IssueFilter + ) { + issues( + first: $first + after: $after + orderBy: ${input.orderBy ?? 'updatedAt'} + includeArchived: true + filter: $filter + ) { + nodes { + id + identifier + title + description + url + priority + priorityLabel + createdAt + updatedAt + completedAt + canceledAt + archivedAt + dueDate + state { name type } + team { key name } + project { name } + creator { name } + assignee { name } + labels { nodes { name } } + comments(last: 20, orderBy: createdAt) { + nodes { + id + body + createdAt + updatedAt + user { name } + externalUser { name } + botActor { name } + } + } + } + pageInfo { hasNextPage endCursor } + } + } + `; + const response = await this.client.client.rawRequest(query, { + first: Math.max(1, Math.min(input.first, 100)), + after: input.after ?? null, + filter: + input.createdBefore || input.updatedAfter || input.updatedBefore + ? { + ...(input.createdBefore + ? { createdAt: { lte: input.createdBefore } } + : {}), + ...(input.updatedAfter || input.updatedBefore + ? { + updatedAt: { + ...(input.updatedAfter + ? { gte: input.updatedAfter } + : {}), + ...(input.updatedBefore + ? { lte: input.updatedBefore } + : {}), + }, + } + : {}), + } + : null, + }); + const data = response.data as { + issues?: { + nodes?: Array<{ + id: string; + identifier: string; + title: string; + description?: string | null; + url: string; + priority?: number | null; + priorityLabel?: string | null; + createdAt: string; + updatedAt: string; + completedAt?: string | null; + canceledAt?: string | null; + archivedAt?: string | null; + dueDate?: string | null; + state?: { name: string; type: string } | null; + team?: { key: string; name: string } | null; + project?: { name: string } | null; + creator?: { name: string } | null; + assignee?: { name: string } | null; + labels?: { nodes?: Array<{ name: string }> }; + comments?: { + nodes?: Array<{ + id: string; + body: string; + createdAt: string; + updatedAt: string; + user?: { name: string } | null; + externalUser?: { name: string } | null; + botActor?: { name: string } | null; + }>; + }; + }>; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + }; + }; + const connection = data.issues; + + return { + issues: (connection?.nodes ?? []).map((issue) => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description ?? null, + url: issue.url, + priority: issue.priority ?? null, + priorityLabel: issue.priorityLabel ?? null, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + completedAt: issue.completedAt ?? null, + canceledAt: issue.canceledAt ?? null, + archivedAt: issue.archivedAt ?? null, + dueDate: issue.dueDate ?? null, + state: issue.state ?? null, + team: issue.team ?? null, + project: issue.project ?? null, + creator: issue.creator ?? null, + assignee: issue.assignee ?? null, + labels: (issue.labels?.nodes ?? []).map((label) => label.name), + comments: (issue.comments?.nodes ?? []).map((comment) => ({ + id: comment.id, + body: comment.body, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + author: + comment.user?.name ?? + comment.externalUser?.name ?? + comment.botActor?.name ?? + null, + })), + })), + pageInfo: { + hasNextPage: connection?.pageInfo?.hasNextPage ?? false, + endCursor: connection?.pageInfo?.endCursor ?? null, + }, + }; + } + /** * Emit an agent activity for a session. * diff --git a/packages/linear/src/types.ts b/packages/linear/src/types.ts index e5d3ef3bc..ff564e428 100644 --- a/packages/linear/src/types.ts +++ b/packages/linear/src/types.ts @@ -262,6 +262,44 @@ export interface LinearIssue { }; } +/** Normalized issue shape used by bounded, read-only Brain collection. */ +export interface LinearBrainIssue { + id: string; + identifier: string; + title: string; + description: string | null; + url: string; + priority: number | null; + priorityLabel: string | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; + canceledAt: string | null; + archivedAt: string | null; + dueDate: string | null; + state: { name: string; type: string } | null; + team: { key: string; name: string } | null; + project: { name: string } | null; + creator: { name: string } | null; + assignee: { name: string } | null; + labels: string[]; + comments: Array<{ + id: string; + body: string; + createdAt: string; + updatedAt: string; + author: string | null; + }>; +} + +export interface LinearBrainIssuePage { + issues: LinearBrainIssue[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; +} + /** * Linear Comment from webhook payload * Note: url and createdAt are optional as Linear doesn't always include them diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 863996826..122d3b4e6 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -429,5 +429,6 @@ export * from './lib/brain-clients'; export * from './lib/brain-corpus'; export * from './lib/brain-mcp'; export * from './lib/brain-github'; +export * from './lib/brain-linear'; export * from './lib/brain-inference'; export * from './lib/brain-source-availability'; diff --git a/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts b/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts new file mode 100644 index 000000000..8523d4c10 --- /dev/null +++ b/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts @@ -0,0 +1,280 @@ +const mocks = vi.hoisted(() => ({ + syncState: new Map< + string, + { + watermark?: Date | null; + backfillCursor?: string | null; + backfillCompletedAt?: Date | null; + } + >(), + staleItems: [] as Array<{ itemId: string; slug: string }>, + listIssues: vi.fn(), + findConnection: vi.fn(), + getValidAccessToken: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: {}, + getBrainSyncState: vi.fn(async (_db: unknown, collectorId: string) => + mocks.syncState.has(collectorId) + ? { collectorId, ...mocks.syncState.get(collectorId) } + : null, + ), + listBrainCollectorItemsBefore: vi.fn(async () => mocks.staleItems), +})); + +vi.mock('@roomote/linear', () => ({ + createLinearClient: vi.fn(() => ({ + listIssuesForBrain: mocks.listIssues, + })), +})); + +vi.mock('../mcp/data', () => ({ + getValidAccessToken: mocks.getValidAccessToken, +})); + +vi.mock('../mcp/linear-connections', () => ({ + findLinearDeploymentMcpConnection: mocks.findConnection, + getLinearDeploymentMetadata: (config: Record | null) => + typeof config?.linearOrganizationId === 'string' + ? { + linearOrganizationId: config.linearOrganizationId, + linearOrganizationName: config.linearOrganizationName ?? null, + } + : null, +})); + +import { + backfillBrainLinearIssuesStep, + buildLinearIssuePage, + collectBrainLinearIssues, +} from '../brain-linear'; + +const issue = { + id: 'Issue-UUID', + identifier: 'ENG-42', + title: 'Keep preview sessions alive', + description: 'A preview should remain reachable while a task is active.', + url: 'https://linear.app/acme/issue/ENG-42', + priority: 2, + priorityLabel: 'High', + createdAt: '2026-08-01T10:00:00.000Z', + updatedAt: '2026-08-03T12:00:00.000Z', + completedAt: '2026-08-03T12:00:00.000Z', + canceledAt: null, + archivedAt: null, + dueDate: '2026-08-10', + state: { name: 'Done', type: 'completed' }, + team: { key: 'ENG', name: 'Engineering' }, + project: { name: 'Previews' }, + creator: { name: 'Ada' }, + assignee: { name: 'Grace' }, + labels: ['bug', 'customer'], + comments: [ + { + id: 'comment-1', + body: 'The controller must renew the lease.', + createdAt: '2026-08-02T09:00:00.000Z', + updatedAt: '2026-08-02T09:00:00.000Z', + author: 'Linus', + }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.syncState.clear(); + mocks.staleItems = []; + mocks.findConnection.mockResolvedValue({ + id: 'connection-1', + authConfig: { + linearOrganizationId: 'Org-UUID', + linearOrganizationName: 'Acme', + }, + }); + mocks.getValidAccessToken.mockResolvedValue('access-token'); +}); + +describe('buildLinearIssuePage', () => { + it('builds a stable canonical issue page without email metadata', () => { + const page = buildLinearIssuePage({ + organizationId: 'Org-UUID', + organizationName: 'Acme', + issue, + }); + + expect(page?.slug).toBe('linear/org-uuid/issues/issue-uuid'); + expect(page?.title).toBe('ENG-42: Keep preview sessions alive'); + expect(page?.content).toContain('type: linear-issue'); + expect(page?.content).toContain('event_date: 2026-08-03'); + expect(page?.content).toContain('team: "Engineering"'); + expect(page?.content).toContain('project: "Previews"'); + expect(page?.content).toContain('state: "Done"'); + expect(page?.content).toContain('## Discussion'); + expect(page?.content).toContain('The controller must renew the lease.'); + expect(page?.content).toContain('provenance: roomote-linear-issues'); + expect(page?.content).not.toContain('@'); + }); + + it('bounds issue and comment text', () => { + const page = buildLinearIssuePage({ + organizationId: 'org', + organizationName: null, + issue: { + ...issue, + description: 'x'.repeat(9_000), + comments: [{ ...issue.comments[0]!, body: 'y'.repeat(2_000) }], + }, + }); + + expect(page?.content).toContain('x'.repeat(8_000)); + expect(page?.content).not.toContain('x'.repeat(8_001)); + expect(page?.content).toContain('y'.repeat(800)); + expect(page?.content).not.toContain('y'.repeat(801)); + }); +}); + +describe('Linear issue collection', () => { + it('persists the upstream cursor inside a frozen incremental window', async () => { + mocks.listIssues.mockResolvedValue({ + issues: [issue], + pageInfo: { hasNextPage: true, endCursor: 'next-page' }, + }); + const now = new Date('2026-08-20T12:00:00.000Z'); + + const result = await collectBrainLinearIssues({ now, limit: 25 }); + + expect(result.pages).toHaveLength(1); + expect(result.itemUpdates).toEqual([ + expect.objectContaining({ + collectorId: 'linear-issues:entity-census-v1', + itemId: 'Issue-UUID', + }), + ]); + expect(mocks.listIssues).toHaveBeenCalledWith({ + first: 25, + after: undefined, + updatedAfter: '2026-07-21T12:00:00.000Z', + updatedBefore: '2026-08-20T11:59:59.000Z', + }); + const cursor = JSON.parse(result.stateUpdates[0]!.cursor!); + expect(cursor).toEqual({ + after: 'next-page', + lowerBound: '2026-07-21T12:00:00.000Z', + upperBound: '2026-08-20T11:59:59.000Z', + }); + }); + + it('advances the watermark only when the frozen window is exhausted', async () => { + mocks.listIssues.mockResolvedValue({ + issues: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + const now = new Date('2026-08-20T12:00:00.000Z'); + + const result = await collectBrainLinearIssues({ now, limit: 100 }); + + expect(result.stateUpdates[0]).toEqual({ + collectorId: 'linear-issues:entity-census-v1:incremental', + watermark: new Date('2026-08-20T11:59:59.000Z'), + cursor: null, + }); + }); + + it('re-arms a completed census after one day', async () => { + mocks.syncState.set('linear-issues:entity-census-v1', { + backfillCompletedAt: new Date('2026-08-19T11:00:00.000Z'), + }); + mocks.listIssues.mockResolvedValue({ + issues: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + + const result = await collectBrainLinearIssues({ + now: new Date('2026-08-20T12:00:00.000Z'), + limit: 100, + }); + + expect(result.stateUpdates).toContainEqual({ + collectorId: 'linear-issues:entity-census-v1', + cursor: null, + backfillCompletedAt: null, + }); + }); + + it('holds all progress when Linear fails', async () => { + mocks.listIssues.mockRejectedValue(new Error('Linear unavailable')); + + await expect( + collectBrainLinearIssues({ + now: new Date('2026-08-20T12:00:00.000Z'), + limit: 100, + }), + ).resolves.toEqual({ + pages: [], + nextSince: null, + stateUpdates: [], + itemUpdates: [], + }); + }); +}); + +describe('Linear issue census', () => { + it('keeps an issue updated during a creation-ordered census visible', async () => { + mocks.listIssues.mockResolvedValue({ + issues: [{ ...issue, updatedAt: '2026-08-21T12:00:00.000Z' }], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + + const result = await backfillBrainLinearIssuesStep({ + cursor: null, + limit: 100, + now: new Date('2026-08-20T12:00:00.000Z'), + }); + + expect(result.done).toBe(false); + expect(result.pages).toHaveLength(1); + expect(result.itemUpdates).toHaveLength(1); + expect(mocks.listIssues).toHaveBeenCalledWith({ + first: 50, + after: null, + orderBy: 'createdAt', + createdBefore: '2026-08-20T12:00:00.000Z', + }); + expect(JSON.parse(result.nextCursor!)).toEqual({ + phase: 'retire', + sweepStartedAt: '2026-08-20T12:00:00.000Z', + }); + }); + + it('retires only inventory unseen by a completed census', async () => { + mocks.staleItems = [ + { itemId: 'deleted-issue', slug: 'linear/org/issues/deleted-issue' }, + ]; + const cursor = JSON.stringify({ + phase: 'retire', + sweepStartedAt: '2026-08-20T12:00:00.000Z', + }); + + const retirement = await backfillBrainLinearIssuesStep({ + cursor, + limit: 100, + }); + expect(retirement).toMatchObject({ + pages: [], + done: false, + pageRetirements: [ + { + collectorId: 'linear-issues:entity-census-v1', + itemId: 'deleted-issue', + slug: 'linear/org/issues/deleted-issue', + }, + ], + }); + + mocks.staleItems = []; + await expect( + backfillBrainLinearIssuesStep({ cursor, limit: 100 }), + ).resolves.toMatchObject({ pages: [], done: true }); + }); +}); diff --git a/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts b/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts index 84eee07b8..31ee27040 100644 --- a/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts +++ b/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ findEnablement: vi.fn(), findSlackInstallation: vi.fn(), hasGithubSources: vi.fn(), + findLinearConnection: vi.fn(), resolveDiscordCredentials: vi.fn(), })); @@ -35,6 +36,12 @@ vi.mock('../brain-github', () => ({ hasBrainGithubSources: mocks.hasGithubSources, })); +vi.mock('../mcp/linear-connections', () => ({ + findLinearDeploymentMcpConnection: mocks.findLinearConnection, + getLinearDeploymentMetadata: (config: Record | null) => + typeof config?.linearOrganizationId === 'string' ? config : null, +})); + import type { BrainSourceRequirement } from '@roomote/types'; import { @@ -54,6 +61,7 @@ describe('resolveBrainSourceRequirements', () => { discord: true, granola: false, notion: true, + linear: true, rippling: false, slack: true, }; @@ -70,6 +78,7 @@ describe('resolveBrainSourceRequirements', () => { 'discord', 'granola', 'notion', + 'linear', 'rippling', 'slack', ]), @@ -135,6 +144,16 @@ describe('isBrainSourceAvailable', () => { await expect(isBrainSourceAvailable('github')).resolves.toBe(true); }); + it('requires an authenticated Linear workspace connection with organization metadata', async () => { + mocks.findLinearConnection.mockResolvedValue({ + authConfig: { linearOrganizationId: 'org-1' }, + }); + await expect(isBrainSourceAvailable('linear')).resolves.toBe(true); + + mocks.findLinearConnection.mockResolvedValue({ authConfig: {} }); + await expect(isBrainSourceAvailable('linear')).resolves.toBe(false); + }); + it('requires Discord credentials and an active guild installation', async () => { mocks.resolveDiscordCredentials.mockResolvedValue({ botToken: 'token' }); mocks.findDiscordInstallation.mockResolvedValue({ id: 'installation-id' }); diff --git a/packages/sdk/src/server/lib/brain-linear.ts b/packages/sdk/src/server/lib/brain-linear.ts new file mode 100644 index 000000000..d6caf97ff --- /dev/null +++ b/packages/sdk/src/server/lib/brain-linear.ts @@ -0,0 +1,422 @@ +import { + db, + getBrainSyncState, + listBrainCollectorItemsBefore, +} from '@roomote/db/server'; +import { + createLinearClient, + type LinearBrainIssue, + type LinearBrainIssuePage, +} from '@roomote/linear'; +import { + BRAIN_COLLECTOR_IDS, + BRAIN_PAGE_TYPES, + brainNamespacePrefix, + renderBrainFrontmatter, +} from '@roomote/types'; + +import { getValidAccessToken } from './mcp/data'; +import { + findLinearDeploymentMcpConnection, + getLinearDeploymentMetadata, +} from './mcp/linear-connections'; + +const LINEAR_MCP_URL = 'https://mcp.linear.app/mcp'; +const ISSUE_BODY_CHAR_CAP = 8_000; +const COMMENT_BODY_CHAR_CAP = 800; +const BACKFILL_PAGE_SIZE = 50; +const RETIREMENT_BATCH_SIZE = 100; +const INITIAL_INCREMENTAL_WINDOW_MS = 30 * 24 * 60 * 60 * 1_000; +const REPLAY_OVERLAP_MS = 1_000; +const CENSUS_INTERVAL_MS = 24 * 60 * 60 * 1_000; +const INCREMENTAL_STATE_ID = `${BRAIN_COLLECTOR_IDS.linearIssues}:incremental`; + +export type BrainLinearPage = { + slug: string; + title: string; + content: string; +}; + +type CollectorStateUpdate = { + collectorId: string; + watermark?: Date; + cursor?: string | null; + backfillCompletedAt?: Date | null; +}; + +type CollectorItemUpdate = { + collectorId: string; + itemId: string; + slug: string; + lastSeenAt: Date; +}; + +type CollectorPageRetirement = { + collectorId: string; + itemId: string; + slug: string; +}; + +type IncrementalCursor = { + after: string; + lowerBound: string; + upperBound: string; +}; + +type BackfillCursor = + | { phase: 'issues'; after: string | null; sweepStartedAt: string } + | { phase: 'retire'; sweepStartedAt: string }; + +type LinearSourceContext = { + organizationId: string; + organizationName: string | null; + listIssues(input: { + first: number; + after?: string | null; + orderBy?: 'createdAt' | 'updatedAt'; + createdBefore?: string | null; + updatedAfter?: string | null; + updatedBefore?: string | null; + }): Promise; +}; + +async function getLinearSourceContext(): Promise { + const connection = await findLinearDeploymentMcpConnection(); + const metadata = getLinearDeploymentMetadata(connection?.authConfig); + if (!connection || !metadata) { + return null; + } + + const accessToken = await getValidAccessToken(connection.id, LINEAR_MCP_URL); + if (!accessToken) { + return null; + } + + const client = createLinearClient(accessToken); + return { + organizationId: metadata.linearOrganizationId, + organizationName: metadata.linearOrganizationName, + listIssues: (input) => client.listIssuesForBrain(input), + }; +} + +function parseIncrementalCursor( + value: string | null, +): IncrementalCursor | null { + if (!value) return null; + + try { + const parsed = JSON.parse(value) as Partial; + if ( + typeof parsed.after !== 'string' || + typeof parsed.lowerBound !== 'string' || + typeof parsed.upperBound !== 'string' || + Number.isNaN(new Date(parsed.lowerBound).getTime()) || + Number.isNaN(new Date(parsed.upperBound).getTime()) + ) { + return null; + } + return parsed as IncrementalCursor; + } catch { + return null; + } +} + +function parseBackfillCursor(value: string | null, now: Date): BackfillCursor { + if (value) { + try { + const parsed = JSON.parse(value) as { + phase?: unknown; + after?: unknown; + sweepStartedAt?: unknown; + }; + if ( + (parsed.phase === 'issues' || parsed.phase === 'retire') && + typeof parsed.sweepStartedAt === 'string' && + !Number.isNaN(new Date(parsed.sweepStartedAt).getTime()) + ) { + return parsed.phase === 'retire' + ? { phase: 'retire', sweepStartedAt: parsed.sweepStartedAt } + : { + phase: 'issues', + after: typeof parsed.after === 'string' ? parsed.after : null, + sweepStartedAt: parsed.sweepStartedAt, + }; + } + } catch { + // Idempotent page writes make restarting a malformed census safe. + } + } + + return { + phase: 'issues', + after: null, + sweepStartedAt: now.toISOString(), + }; +} + +function yamlString(value: string): string { + return JSON.stringify(value); +} + +function issueEventDate(issue: LinearBrainIssue): string { + return (issue.completedAt ?? issue.canceledAt ?? issue.createdAt).slice( + 0, + 10, + ); +} + +export function buildLinearIssuePage(input: { + organizationId: string; + organizationName: string | null; + issue: LinearBrainIssue; +}): BrainLinearPage | null { + const { issue } = input; + if (!issue.id || !issue.identifier || !issue.title) { + return null; + } + + const title = `${issue.identifier}: ${issue.title}`; + const eventDate = issueEventDate(issue); + const discussion = issue.comments.flatMap((comment) => { + const body = comment.body.trim(); + return body + ? [ + `**${comment.author ?? 'unknown'}** (${comment.createdAt}):`, + body.slice(0, COMMENT_BODY_CHAR_CAP), + '', + ] + : []; + }); + const description = issue.description?.trim() ?? ''; + const content = [ + ...renderBrainFrontmatter({ + type: BRAIN_PAGE_TYPES.linearIssue, + title, + created: issue.createdAt, + fields: [ + `event_date: ${eventDate}`, + `linear_issue_id: ${yamlString(issue.id)}`, + `identifier: ${yamlString(issue.identifier)}`, + `organization_id: ${yamlString(input.organizationId)}`, + input.organizationName && + `organization: ${yamlString(input.organizationName)}`, + issue.team && `team: ${yamlString(issue.team.name)}`, + issue.project && `project: ${yamlString(issue.project.name)}`, + issue.state && `state: ${yamlString(issue.state.name)}`, + issue.priorityLabel && `priority: ${yamlString(issue.priorityLabel)}`, + issue.creator && `creator: ${yamlString(issue.creator.name)}`, + issue.assignee && `assignee: ${yamlString(issue.assignee.name)}`, + issue.labels.length > 0 && + `labels: ${yamlString(issue.labels.join(', '))}`, + issue.dueDate && `due_date: ${issue.dueDate}`, + issue.completedAt && `completed_at: ${issue.completedAt}`, + issue.canceledAt && `canceled_at: ${issue.canceledAt}`, + issue.archivedAt && `archived_at: ${issue.archivedAt}`, + `updated_at: ${issue.updatedAt}`, + 'provenance: roomote-linear-issues', + ], + }), + '', + `# ${title}`, + '', + ...(description ? [description.slice(0, ISSUE_BODY_CHAR_CAP), ''] : []), + ...(discussion.length > 0 ? ['## Discussion', '', ...discussion] : []), + issue.url, + ].join('\n'); + + return { + slug: `${brainNamespacePrefix('linear')}${input.organizationId.toLowerCase()}/issues/${issue.id.toLowerCase()}`, + title, + content, + }; +} + +function pagesAndItems(input: { + source: LinearSourceContext; + issues: LinearBrainIssue[]; + seenAt: Date; +}): { pages: BrainLinearPage[]; itemUpdates: CollectorItemUpdate[] } { + const pages: BrainLinearPage[] = []; + const itemUpdates: CollectorItemUpdate[] = []; + + for (const issue of input.issues) { + const page = buildLinearIssuePage({ + organizationId: input.source.organizationId, + organizationName: input.source.organizationName, + issue, + }); + if (!page) continue; + + pages.push(page); + itemUpdates.push({ + collectorId: BRAIN_COLLECTOR_IDS.linearIssues, + itemId: issue.id, + slug: page.slug, + lastSeenAt: input.seenAt, + }); + } + + return { pages, itemUpdates }; +} + +export async function collectBrainLinearIssues(input: { + now: Date; + limit: number; +}): Promise<{ + pages: BrainLinearPage[]; + nextSince: null; + stateUpdates: CollectorStateUpdate[]; + itemUpdates: CollectorItemUpdate[]; +}> { + try { + const source = await getLinearSourceContext(); + if (!source) { + return { pages: [], nextSince: null, stateUpdates: [], itemUpdates: [] }; + } + + const [incrementalState, backfillState] = await Promise.all([ + getBrainSyncState(db, INCREMENTAL_STATE_ID), + getBrainSyncState(db, BRAIN_COLLECTOR_IDS.linearIssues), + ]); + const cursor = parseIncrementalCursor( + incrementalState?.backfillCursor ?? null, + ); + const lowerBound = + cursor?.lowerBound ?? + ( + incrementalState?.watermark ?? + new Date(input.now.getTime() - INITIAL_INCREMENTAL_WINDOW_MS) + ).toISOString(); + const upperBound = + cursor?.upperBound ?? + new Date(input.now.getTime() - REPLAY_OVERLAP_MS).toISOString(); + const result = await source.listIssues({ + first: input.limit, + after: cursor?.after, + updatedAfter: lowerBound, + updatedBefore: upperBound, + }); + const { pages, itemUpdates } = pagesAndItems({ + source, + issues: result.issues, + seenAt: input.now, + }); + const stateUpdates: CollectorStateUpdate[] = []; + + if (result.pageInfo.hasNextPage && result.pageInfo.endCursor) { + stateUpdates.push({ + collectorId: INCREMENTAL_STATE_ID, + cursor: JSON.stringify({ + after: result.pageInfo.endCursor, + lowerBound, + upperBound, + } satisfies IncrementalCursor), + }); + } else { + stateUpdates.push({ + collectorId: INCREMENTAL_STATE_ID, + watermark: new Date(upperBound), + cursor: null, + }); + } + + if ( + backfillState?.backfillCompletedAt && + input.now.getTime() - backfillState.backfillCompletedAt.getTime() >= + CENSUS_INTERVAL_MS + ) { + stateUpdates.push({ + collectorId: BRAIN_COLLECTOR_IDS.linearIssues, + cursor: null, + backfillCompletedAt: null, + }); + } + + return { pages, nextSince: null, stateUpdates, itemUpdates }; + } catch (error) { + console.warn( + `[brainLinear] issue sync failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return { pages: [], nextSince: null, stateUpdates: [], itemUpdates: [] }; + } +} + +export async function backfillBrainLinearIssuesStep(input: { + cursor: string | null; + limit: number; + now?: Date; +}): Promise<{ + pages: BrainLinearPage[]; + nextCursor: string | null; + done: boolean; + itemUpdates?: CollectorItemUpdate[]; + pageRetirements?: CollectorPageRetirement[]; +}> { + try { + const source = await getLinearSourceContext(); + if (!source) { + return { pages: [], nextCursor: input.cursor, done: false }; + } + + const cursor = parseBackfillCursor(input.cursor, input.now ?? new Date()); + const sweepStartedAt = new Date(cursor.sweepStartedAt); + + if (cursor.phase === 'retire') { + const stale = await listBrainCollectorItemsBefore( + db, + BRAIN_COLLECTOR_IDS.linearIssues, + sweepStartedAt, + Math.min(input.limit, RETIREMENT_BATCH_SIZE), + ); + if (stale.length === 0) { + return { pages: [], nextCursor: input.cursor, done: true }; + } + + return { + pages: [], + nextCursor: input.cursor, + done: false, + pageRetirements: stale.map((item) => ({ + collectorId: BRAIN_COLLECTOR_IDS.linearIssues, + itemId: item.itemId, + slug: item.slug, + })), + }; + } + + const result = await source.listIssues({ + first: Math.min(input.limit, BACKFILL_PAGE_SIZE), + after: cursor.after, + orderBy: 'createdAt', + createdBefore: cursor.sweepStartedAt, + }); + const { pages, itemUpdates } = pagesAndItems({ + source, + issues: result.issues, + seenAt: sweepStartedAt, + }); + + return { + pages, + itemUpdates, + done: false, + nextCursor: + result.pageInfo.hasNextPage && result.pageInfo.endCursor + ? JSON.stringify({ + phase: 'issues', + after: result.pageInfo.endCursor, + sweepStartedAt: cursor.sweepStartedAt, + } satisfies BackfillCursor) + : JSON.stringify({ + phase: 'retire', + sweepStartedAt: cursor.sweepStartedAt, + } satisfies BackfillCursor), + }; + } catch (error) { + console.warn( + `[brainLinear] issue backfill failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return { pages: [], nextCursor: input.cursor, done: false }; + } +} diff --git a/packages/sdk/src/server/lib/brain-source-availability.ts b/packages/sdk/src/server/lib/brain-source-availability.ts index 4c9b7d681..6a3a25d6d 100644 --- a/packages/sdk/src/server/lib/brain-source-availability.ts +++ b/packages/sdk/src/server/lib/brain-source-availability.ts @@ -22,6 +22,10 @@ import { } from '@roomote/types'; import { hasBrainGithubSources } from './brain-github'; +import { + findLinearDeploymentMcpConnection, + getLinearDeploymentMetadata, +} from './mcp/linear-connections'; type BrainMcpSourceId = 'granola' | 'notion' | 'rippling'; type BrainMcpSourceConfig = @@ -112,6 +116,12 @@ const BRAIN_SOURCE_AVAILABILITY = { granola: async () => Boolean(await findBrainSourceConnectionConfig('granola')), github: hasBrainGithubSources, + linear: async () => { + const connection = await findLinearDeploymentMcpConnection(); + return Boolean( + connection && getLinearDeploymentMetadata(connection.authConfig), + ); + }, rippling: async () => Boolean(await findBrainSourceConnectionConfig('rippling')), } satisfies Record Promise>; diff --git a/packages/types/src/brain.test.ts b/packages/types/src/brain.test.ts index 1d3be5a23..29116f2e5 100644 --- a/packages/types/src/brain.test.ts +++ b/packages/types/src/brain.test.ts @@ -32,6 +32,9 @@ describe('resolveBrainNamespaceId', () => { ); expect(resolveBrainNamespaceId('people/roomote-member-abc')).toBe('people'); expect(resolveBrainNamespaceId('daily/digests/2026-01-02')).toBe('daily'); + expect(resolveBrainNamespaceId('linear/org/issues/issue-id')).toBe( + 'linear', + ); expect(resolveBrainNamespaceId('discord/123/456/2026-01-02/000')).toBe( 'discord', ); @@ -59,6 +62,9 @@ describe('resolveBrainSourceIdForCollector', () => { expect( resolveBrainSourceIdForCollector('github-issues:occurrence-date-v3'), ).toBe('github-issues'); + expect( + resolveBrainSourceIdForCollector('linear-issues:entity-census-v1'), + ).toBe('linear-issues'); }); it('folds a fanned-out collector’s per-partition rows into one source', () => { diff --git a/packages/types/src/brain.ts b/packages/types/src/brain.ts index f7279e9b1..ab7ef916e 100644 --- a/packages/types/src/brain.ts +++ b/packages/types/src/brain.ts @@ -31,6 +31,7 @@ export const BRAIN_NAMESPACES = [ { id: 'memories', prefix: 'memories/', label: 'Conversation memories' }, { id: 'prs', prefix: 'prs/', label: 'Pull requests' }, { id: 'github', prefix: 'github/', label: 'GitHub issues' }, + { id: 'linear', prefix: 'linear/', label: 'Linear issues' }, { id: 'slack', prefix: 'slack/', label: 'Slack' }, { id: 'discord', prefix: 'discord/', label: 'Discord' }, { id: 'notion', prefix: 'notion/', label: 'Notion' }, @@ -121,6 +122,7 @@ export const BRAIN_COLLECTOR_IDS = { slackPublicChannels: 'slack-public-channels:entity-timeline-v3', discordPublicChannels: 'discord-public-channels:entity-timeline-v1', githubIssues: 'github-issues:occurrence-date-v3', + linearIssues: 'linear-issues:entity-census-v1', notionPages: 'notion-pages', granolaMeetings: 'granola-meetings:entity-timeline-v3', } as const; @@ -137,6 +139,7 @@ export const BRAIN_PAGE_TYPES = { conversationMemory: 'conversation-memory', pullRequest: 'pull-request', githubIssue: 'github-issue', + linearIssue: 'linear-issue', slackDay: 'slack', discordDay: 'discord', meeting: 'meeting', @@ -273,6 +276,16 @@ export const BRAIN_SOURCES = [ collectorIds: [BRAIN_COLLECTOR_IDS.githubIssues] as readonly string[], requires: 'github', }, + { + id: 'linear-issues', + label: 'Linear issues', + description: + 'Issues and bounded discussion from the connected Linear workspace, refreshed as they change upstream.', + namespaceId: 'linear', + collectorIdPrefix: 'linear-issues', + collectorIds: [BRAIN_COLLECTOR_IDS.linearIssues] as readonly string[], + requires: 'linear', + }, { id: 'notion-pages', label: 'Notion', @@ -380,7 +393,7 @@ export function parseBrainBackfillCompletedCount( * chosen from gbrain's own description, which is written for a different * product and routes to tools this deployment does not expose. */ -export const BRAIN_MCP_READ_INSTRUCTIONS = `The \`gbrain\` server is this deployment's shared memory (the Brain). It holds memories distilled from completed tasks plus activity from connected integrations (pull requests, Slack and Discord channels, meeting notes, GitHub issues), each stored as a page with citations. +export const BRAIN_MCP_READ_INSTRUCTIONS = `The \`gbrain\` server is this deployment's shared memory (the Brain). It holds memories distilled from completed tasks plus activity from connected integrations (pull requests, Slack and Discord channels, meeting notes, GitHub issues, Linear issues), each stored as a page with citations. ## Using what it knows @@ -394,7 +407,7 @@ Which tool: - \`query\` when you are describing a concept and do not know how the Brain words it. It expands your phrasing into related queries, so it finds pages that talk about the same thing in different language. This is the default, and the right choice for that first pass. - \`search\` when you already know the exact token: a slug, a repository name, an error string, a person's handle. Cheaper than \`query\` because it skips the expansion step. - \`entity\` for one known person. It resolves names and linked provider handles against canonical deployment-member cards without an LLM call. -- \`list_pages\` to enumerate rather than guess, and to answer "what is in the Brain" or "what happened recently" (it sorts by recency). Use it before ever concluding the Brain is empty. Pages are namespaced: \`people/\`, \`tasks/\`, \`prs/\`, \`slack/\`, \`discord/\`, \`notion/\`, \`meetings/\`, \`github/\`. +- \`list_pages\` to enumerate rather than guess, and to answer "what is in the Brain" or "what happened recently" (it sorts by recency). Use it before ever concluding the Brain is empty. Pages are namespaced: \`people/\`, \`tasks/\`, \`prs/\`, \`slack/\`, \`discord/\`, \`notion/\`, \`meetings/\`, \`github/\`, \`linear/\`. - \`get_page\` on a slug for a page's full text, once a search result looks relevant. A result set that comes back populated is not proof of coverage, and one query returning nothing is not proof of absence. If the answer matters, try the other phrasing or list the namespace before deciding the Brain has nothing.