diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e14779..9ae9d3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ published version with a date and open a fresh empty `[Unreleased]` above it. ### Added +- `@relayfile/adapter-linear` now owns cursor-safe AgentActivity reconciliation so consumers can prevent duplicate terminal activities after ambiguous provider writes. - `@relayfile/adapter-github/webhook-identity` now exports fail-closed check-run pull-request identity parsing for API and HTML URLs, including repository ownership validation for webhook consumers. - `@relayfile/adapter-gitlab` now creates issues, branches, and merge requests from file-native drafts; accepts or closes/reopens merge requests through canonical sidecars; and publishes schemas, examples, catalog paths, and `LAYOUT.md` guidance for every supported GitLab writeback route. - `@relayfile/adapter-github` now declares and routes pull-request `ready_for_review`, `labeled`, and `unlabeled` webhooks so review flows can wake on draft and policy-label transitions. diff --git a/packages/linear/src/agent-activity-reconciliation.test.ts b/packages/linear/src/agent-activity-reconciliation.test.ts new file mode 100644 index 00000000..e3ed3bba --- /dev/null +++ b/packages/linear/src/agent-activity-reconciliation.test.ts @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { reconcileLinearAgentActivity } from './agent-activity-reconciliation.js'; + +describe('Linear AgentActivity reconciliation', () => { + it('owns cursor pagination and finds exact frozen content', async () => { + const cursors: unknown[] = []; + const result = await reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', + execute: async (request) => { + const variables = request.body.variables as { + sessionId: string; + after: string | null; + }; + cursors.push(variables.after); + assert.equal(request.action, 'list_agent_activities'); + assert.match(String(request.body.query), /RelayfileAgentSessionActivities/); + if (variables.after === null) { + return { + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [ + { + id: 'prompt-1', + createdAt: '2026-09-28T09:58:00.000Z', + content: { + __typename: 'AgentActivityPromptContent', + body: 'Please keep going.', + }, + }, + { + id: 'old-identical-activity', + createdAt: '2026-09-28T09:59:00.000Z', + content: { + __typename: 'AgentActivityResponseContent', + body: 'Ready for review.', + }, + }, + ], + pageInfo: { hasNextPage: true, endCursor: 'page-2' }, + }, + }, + }, + }, + }; + } + return { + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [{ + id: 'activity-1', + createdAt: '2026-09-28T10:00:01.000Z', + content: { + __typename: 'AgentActivityResponseContent', + body: 'Ready for review.', + }, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }; + }, + }); + + assert.deepEqual(cursors, [null, 'page-2']); + assert.deepEqual(result, { + found: true, + externalId: 'activity-1', + status: 200, + }); + }); + + it('fails closed on provider errors and repeated cursors', async () => { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'error', body: 'Failed.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', + execute: async () => ({ + ok: true, + status: 200, + data: { errors: [{ message: 'permission denied' }] }, + }), + }), + /permission denied/, + ); + + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'error', body: 'Failed.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', + execute: async () => ({ + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [], + pageInfo: { hasNextPage: true, endCursor: 'same' }, + }, + }, + }, + }, + }), + }), + /invalid cursor/, + ); + }); + + it('fails closed when successful history responses are incomplete', async () => { + const malformedPayloads = [ + { data: { agentSession: null } }, + { data: { agentSession: {} } }, + { data: { agentSession: { activities: { pageInfo: { + hasNextPage: false, + } } } } }, + { data: { agentSession: { activities: { + nodes: [], + pageInfo: {}, + } } } }, + ]; + + for (const data of malformedPayloads) { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', + execute: async () => ({ ok: true, status: 200, data }), + }), + /malformed history/, + ); + } + }); + + it('rejects malformed GraphQL error envelopes before trusting history', async () => { + for (const errors of [null, 'upstream failed', { message: 'failed' }]) { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', + execute: async () => ({ + ok: true, + status: 200, + data: { + errors, + data: { + agentSession: { + activities: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }), + }), + /GraphQL errors is not an array/, + ); + } + }); + + it('requires a stable provider id before confirming a content match', async () => { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', + execute: async () => ({ + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [{ + createdAt: '2026-09-28T10:00:01.000Z', + content: { + __typename: 'AgentActivityResponseContent', + body: 'Ready for review.', + }, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }), + }), + /missing an id, createdAt, or content/, + ); + }); + + it('rejects incomplete activity content before reporting absence', async () => { + const malformedContent = [ + {}, + { __typename: 'AgentActivityFutureContent' }, + { __typename: 'AgentActivityResponseContent' }, + { + __typename: 'AgentActivityActionContent', + action: 'deploy', + parameter: null, + }, + ]; + + for (const content of malformedContent) { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', + execute: async () => ({ + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [{ + id: 'activity-1', + createdAt: '2026-09-28T10:00:01.000Z', + content, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }), + }), + /malformed history/, + ); + } + }); +}); diff --git a/packages/linear/src/agent-activity-reconciliation.ts b/packages/linear/src/agent-activity-reconciliation.ts new file mode 100644 index 00000000..d53946d1 --- /dev/null +++ b/packages/linear/src/agent-activity-reconciliation.ts @@ -0,0 +1,210 @@ +import type { + LinearAgentActivity, + LinearAgentActivityType, + LinearWritebackRequest, +} from './types.js'; + +export interface LinearAgentActivityProxyResponse { + readonly ok: boolean; + readonly status: number; + readonly data: unknown; +} + +export interface LinearAgentActivityReconciliationResult { + readonly found: boolean; + readonly externalId?: string; + readonly status: number; +} + +export type LinearAgentActivityRequestExecutor = ( + request: LinearWritebackRequest, +) => Promise; + +const AGENT_SESSION_ACTIVITIES_QUERY = ` + query RelayfileAgentSessionActivities($sessionId: String!, $after: String) { + agentSession(id: $sessionId) { + activities(first: 50, after: $after) { + nodes { + id + createdAt + content { + __typename + ... on AgentActivityThoughtContent { body } + ... on AgentActivityElicitationContent { body } + ... on AgentActivityActionContent { action parameter result } + ... on AgentActivityResponseContent { body } + ... on AgentActivityErrorContent { body } + ... on AgentActivityPromptContent { body } + } + } + pageInfo { hasNextPage endCursor } + } + } + } +`; + +function record(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function contentMatches(value: unknown, expected: LinearAgentActivity): boolean { + const content = record(value); + if (!content) return false; + const typeByTypename: Record = { + AgentActivityThoughtContent: 'thought', + AgentActivityElicitationContent: 'elicitation', + AgentActivityActionContent: 'action', + AgentActivityResponseContent: 'response', + AgentActivityErrorContent: 'error', + }; + const type = typeof content.__typename === 'string' + ? typeByTypename[content.__typename] + : undefined; + return type === expected.type && + optionalString(content.body) === expected.body && + optionalString(content.action) === expected.action && + optionalString(content.parameter) === expected.parameter && + optionalString(content.result) === expected.result; +} + +function validateActivityContent(value: unknown): Record { + const content = record(value); + const typename = optionalString(content?.__typename); + if (!content || !typename) { + malformedHistory('activity content is missing a typename'); + } + if ( + typename === 'AgentActivityThoughtContent' || + typename === 'AgentActivityElicitationContent' || + typename === 'AgentActivityResponseContent' || + typename === 'AgentActivityErrorContent' || + typename === 'AgentActivityPromptContent' + ) { + if (typeof content.body !== 'string') { + malformedHistory(`${typename} is missing its body`); + } + } else if (typename === 'AgentActivityActionContent') { + for (const field of ['action', 'parameter', 'result'] as const) { + if ( + !Object.prototype.hasOwnProperty.call(content, field) || + (content[field] !== null && typeof content[field] !== 'string') + ) { + malformedHistory(`${typename} has an invalid ${field}`); + } + } + } else { + malformedHistory(`unsupported activity content typename ${typename}`); + } + return content; +} + +function graphqlError(data: unknown): string | undefined { + const envelope = record(data); + if (!envelope || !Object.prototype.hasOwnProperty.call(envelope, 'errors')) { + return undefined; + } + if (!Array.isArray(envelope.errors)) { + malformedHistory('GraphQL errors is not an array'); + } + if (envelope.errors.length === 0) return undefined; + return envelope.errors + .map((entry) => optionalString(record(entry)?.message)) + .filter((message): message is string => Boolean(message)) + .join('; ') || 'Linear GraphQL request failed'; +} + +function request(sessionId: string, after: string | null): LinearWritebackRequest { + return { + action: 'list_agent_activities', + method: 'POST', + endpoint: '/graphql', + body: { + query: AGENT_SESSION_ACTIVITIES_QUERY, + variables: { sessionId, after }, + }, + }; +} + +function malformedHistory(message: string): never { + throw new Error(`Linear activity reconciliation returned malformed history: ${message}`); +} + +/** + * Reconcile an ambiguously completed AgentActivity create against Linear's + * frozen session history. The adapter owns the GraphQL contract, pagination, + * cursor validation, and provider response parsing; callers only supply their + * authenticated request executor. + */ +export async function reconcileLinearAgentActivity(input: { + readonly sessionId: string; + readonly activity: LinearAgentActivity; + readonly createdAtOrAfter: string; + readonly execute: LinearAgentActivityRequestExecutor; +}): Promise { + const createdAtOrAfter = Date.parse(input.createdAtOrAfter); + if (!Number.isFinite(createdAtOrAfter)) { + throw new Error('Linear activity reconciliation requires a valid creation-time boundary'); + } + let after: string | null = null; + const seenCursors = new Set(); + for (;;) { + const response = await input.execute(request(input.sessionId, after)); + const providerError = graphqlError(response.data); + if (!response.ok || providerError) { + throw new Error( + providerError ?? + `Linear activity reconciliation failed with status ${response.status}`, + ); + } + const envelope = record(response.data); + const data = record(envelope?.data); + const session = record(data?.agentSession); + const activities = record(session?.activities); + if (!envelope || !data || !session || !activities) { + malformedHistory('missing agent session activities'); + } + if (!Array.isArray(activities.nodes)) { + malformedHistory('activities.nodes is not an array'); + } + const pageInfo = record(activities.pageInfo); + if (!pageInfo || typeof pageInfo.hasNextPage !== 'boolean') { + malformedHistory('pageInfo.hasNextPage is not a boolean'); + } + const nodes = activities.nodes; + for (const nodeValue of nodes) { + const node = record(nodeValue); + const externalId = optionalString(node?.id)?.trim(); + const createdAtValue = optionalString(node?.createdAt); + const createdAt = createdAtValue ? Date.parse(createdAtValue) : Number.NaN; + if (!node || !externalId || !Number.isFinite(createdAt)) { + malformedHistory('activity node is missing an id, createdAt, or content'); + } + const content = validateActivityContent(node.content); + if ( + createdAt >= createdAtOrAfter && + contentMatches(content, input.activity) + ) { + return { + found: true, + externalId, + status: response.status, + }; + } + } + if (!pageInfo.hasNextPage) { + return { found: false, status: response.status }; + } + const endCursor = optionalString(pageInfo.endCursor); + if (!endCursor || seenCursors.has(endCursor)) { + throw new Error('Linear activity reconciliation returned an invalid cursor'); + } + seenCursors.add(endCursor); + after = endCursor; + } +} diff --git a/packages/linear/src/index.ts b/packages/linear/src/index.ts index 61bcc123..30560dfb 100644 --- a/packages/linear/src/index.ts +++ b/packages/linear/src/index.ts @@ -11,6 +11,7 @@ export * from './webhook-normalizer.js'; export * from './types.js'; export * from './queries.js'; export * from './writeback.js'; +export * from './agent-activity-reconciliation.js'; export * from './emit-auxiliary-files.js'; export * from './resources.js'; diff --git a/packages/linear/src/types.ts b/packages/linear/src/types.ts index e1def592..9c5d8018 100644 --- a/packages/linear/src/types.ts +++ b/packages/linear/src/types.ts @@ -49,6 +49,7 @@ export interface LinearWritebackRequest { | 'create-project' | 'delete_issue' | 'delete_label' + | 'list_agent_activities' | 'update_issue' | 'update_label' | 'update-project'; diff --git a/packages/linear/src/writeback.ts b/packages/linear/src/writeback.ts index 7f37bdc4..e65897b7 100644 --- a/packages/linear/src/writeback.ts +++ b/packages/linear/src/writeback.ts @@ -3,6 +3,12 @@ import { resources } from './resources.js'; import type { JsonValue, LinearAgentActivity, LinearAgentActivityType, LinearWritebackRequest } from './types.js'; export { ReadOnlyFieldError } from '@relayfile/adapter-core'; +export { reconcileLinearAgentActivity } from './agent-activity-reconciliation.js'; +export type { + LinearAgentActivityProxyResponse, + LinearAgentActivityReconciliationResult, + LinearAgentActivityRequestExecutor, +} from './agent-activity-reconciliation.js'; /** * Mounted Linear records used to resolve human-readable issue-create