From 8ff01e3bc3ee69e3eb4ce7dc9f8b123e70ad03f3 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 24 Jun 2026 11:21:52 +0200 Subject: [PATCH] fix(factory): resolve Linear states without pinned IDs --- src/linear/state-resolver.test.ts | 103 ++++++++++++ src/linear/state-resolver.ts | 152 ++++++++++++++++-- .../relayfile-cloud-mount-client.test.ts | 3 + src/mount/relayfile-cloud-mount-client.ts | 1 + 4 files changed, 245 insertions(+), 14 deletions(-) diff --git a/src/linear/state-resolver.test.ts b/src/linear/state-resolver.test.ts index 56ef7de..f1a3934 100644 --- a/src/linear/state-resolver.test.ts +++ b/src/linear/state-resolver.test.ts @@ -21,6 +21,25 @@ function makeReader( } } +function makeIssueFallbackReader( + issues: Record, +): LinearStateReader & { reads: string[] } { + const reads: string[] = [] + return { + reads, + async readFile(path: string) { + reads.push(path) + if (path === '/linear/states/_index.json') throw new Error('not found') + const issue = issues[path] + if (issue) return { content: { provider: 'linear', objectType: 'issue', payload: issue } } + throw Object.assign(new Error('not found'), { code: 'ENOENT' }) + }, + async listTree(prefix: string) { + return prefix === '/linear/issues' ? Object.keys(issues) : [] + }, + } +} + const AR_STATES = [{ id: 's1' }, { id: 's2' }, { id: 's3' }, { id: 's4' }, { id: 's5' }] const AR_RECORDS = { s1: { name: 'Ready for Agent', team_key: 'AR' }, @@ -210,6 +229,20 @@ describe('resolveFactoryStates', () => { })).rejects.toThrow(/ambiguous/) }) + it('surfaces a misspelled OPTIONAL role when the states catalog is readable', async () => { + const reader = makeReader(AR_STATES, AR_RECORDS) + await expect(resolveFactoryStates(reader, { + states: { + readyForAgent: 'Ready for Agent', + agentImplementing: 'Agent Implementing', + inPlanning: 'In Planning', + done: 'Done', + humanReview: 'In Human Revue', + }, + teams: ['AR'], + })).rejects.toThrow(/No Linear workflow state named "In Human Revue" for team "AR"/) + }) + it('leaves an OPTIONAL role unresolved when the states catalog is unavailable', async () => { // No /linear/states (e.g. unreadable under the mount token): required roles // fall back to pinned UUIDs and the optional humanReview is left unresolved, @@ -297,4 +330,74 @@ describe('resolveFactoryStates name-only sync tolerance (relayfile-adapters#205) expect(states.idFor('AR', 'readyForAgent')).toBe('s1') expect(states.idForName('Ready for Agent', 'AR')).toBe('s1') }) + + it('falls back to synced issue state data when the states catalog is absent', async () => { + const reader = makeIssueFallbackReader({ + '/linear/issues/AR-1__ready.json': { stateId: 's-ready', state: { name: 'Ready for Agent' }, team: { key: 'AR' } }, + '/linear/issues/AR-2__impl.json': { stateId: 's-impl', state: { name: 'Agent Implementing' }, team: { key: 'AR' } }, + '/linear/issues/AR-3__plan.json': { stateId: 's-plan', state: { name: 'In Planning' }, team: { key: 'AR' } }, + '/linear/issues/AR-4__done.json': { stateId: 's-done', state: { name: 'Done' }, team: { key: 'AR' } }, + '/linear/issues/AR-5__review.json': { stateId: 's-review', state: { name: 'In Human Review' }, team: { key: 'AR' } }, + }) + + const states = await resolveFactoryStates(reader, { + states: { + readyForAgent: 'Ready for Agent', + agentImplementing: 'Agent Implementing', + inPlanning: 'In Planning', + done: 'Done', + humanReview: 'In Human Review', + }, + teams: ['AR'], + }) + + expect(states.idFor('AR', 'readyForAgent')).toBe('s-ready') + expect(states.idFor('AR', 'done')).toBe('s-done') + expect(states.roleOf('s-impl')).toBe('agentImplementing') + expect(states.idForName('Done', 'AR')).toBe('s-done') + expect(states.hasHumanReview('AR')).toBe(true) + }) + + it('stops issue fallback scanning once configured state names are found', async () => { + const issues: Record = { + '/linear/issues/AR-1__ready.json': { stateId: 's-ready', state: { name: 'Ready for Agent' }, team: { key: 'AR' } }, + '/linear/issues/AR-2__impl.json': { stateId: 's-impl', state: { name: 'Agent Implementing' }, team: { key: 'AR' } }, + '/linear/issues/AR-3__plan.json': { stateId: 's-plan', state: { name: 'In Planning' }, team: { key: 'AR' } }, + '/linear/issues/AR-4__done.json': { stateId: 's-done', state: { name: 'Done' }, team: { key: 'AR' } }, + } + for (let index = 0; index < 200; index += 1) { + issues[`/linear/issues/ZZ-${index}.json`] = { stateId: `junk-${index}`, state: { name: `Junk ${index}` }, team: { key: 'AR' } } + } + const reader = makeIssueFallbackReader(issues) + + const states = await resolveFactoryStates(reader, { + states: { + readyForAgent: 'Ready for Agent', + agentImplementing: 'Agent Implementing', + inPlanning: 'In Planning', + done: 'Done', + }, + teams: ['AR'], + }) + + expect(states.idFor('AR', 'done')).toBe('s-done') + expect(reader.reads).not.toContain('/linear/issues/ZZ-199.json') + expect(reader.reads.length).toBeLessThan(30) + }) + + it('caps issue fallback scanning when configured state names are not found', async () => { + const issues: Record = {} + for (let index = 0; index < 200; index += 1) { + issues[`/linear/issues/ZZ-${index}.json`] = { stateId: `junk-${index}`, state: { name: `Junk ${index}` }, team: { key: 'AR' } } + } + const reader = makeIssueFallbackReader(issues) + + await expect(resolveFactoryStates(reader, { + states: { readyForAgent: 'Ready for Agent' }, + teams: ['AR'], + })).rejects.toThrow(/No Linear workflow state named "Ready for Agent" for team "AR"/) + + expect(reader.reads).not.toContain('/linear/issues/ZZ-199.json') + expect(reader.reads.filter((path) => path.startsWith('/linear/issues/')).length).toBe(150) + }) }) diff --git a/src/linear/state-resolver.ts b/src/linear/state-resolver.ts index 90d23e4..25f9c83 100644 --- a/src/linear/state-resolver.ts +++ b/src/linear/state-resolver.ts @@ -15,11 +15,13 @@ const stateCanonicalPath = (id: string): string => `/linear/states/${id}.json` // Cap concurrent canonical-record reads so a large states catalog can't flood a // remote/cloud mount (rate limits, timeouts, fd exhaustion). const CATALOG_READ_CONCURRENCY = 10 +const ISSUE_FALLBACK_SCAN_LIMIT = 150 // Minimal read surface so the resolver is decoupled from the full MountClient // and trivially testable with an in-memory map. export interface LinearStateReader { readFile(path: string): Promise<{ content: unknown }> + listTree?(prefix: string): Promise } type RoleNames = Partial> @@ -98,6 +100,11 @@ interface StateRecord { teamName?: string } +interface TargetStateLookup { + name: string + teamToken?: string +} + const norm = (value: string | undefined): string => (value ?? '').trim().toLowerCase() const asArray = (value: unknown): unknown[] => { @@ -119,6 +126,11 @@ const unwrap = (value: unknown): Record => { : record } +const asRecord = (value: unknown): Record | undefined => + value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined + const str = (value: unknown): string | undefined => (typeof value === 'string' && value ? value : undefined) // Lazily load and cache the /linear/states catalog. Only invoked when at least @@ -133,7 +145,16 @@ const isCatalogUnavailable = (error: unknown): boolean => error instanceof Catal class StateCatalog { #records: StateRecord[] | undefined - constructor(private readonly reader: LinearStateReader) {} + #isFallback = false + + constructor( + private readonly reader: LinearStateReader, + private readonly targetLookups: readonly TargetStateLookup[], + ) {} + + get isFallback(): boolean { + return this.#isFallback + } async records(): Promise { if (this.#records) return this.#records @@ -141,6 +162,12 @@ class StateCatalog { try { index = (await this.reader.readFile(STATES_INDEX_PATH)).content } catch (error) { + const issueRecords = await this.recordsFromIssues() + if (issueRecords.length > 0) { + this.#isFallback = true + this.#records = issueRecords + return issueRecords + } throw new CatalogUnavailableError( `Cannot resolve Linear state names: ${STATES_INDEX_PATH} is unavailable ` + `(${error instanceof Error ? error.message : String(error)}). ` + @@ -170,6 +197,55 @@ class StateCatalog { return records } + async recordsFromIssues(): Promise { + if (!this.reader.listTree) return [] + let paths: string[] + try { + paths = await this.reader.listTree('/linear/issues') + } catch { + return [] + } + + const byKey = new Map() + const foundTargets = new Set() + const issuePaths = paths.filter((path) => + path.endsWith('.json') && + !path.includes('/comments/') && + !path.startsWith('/linear/issues/by-uuid/'), + ) + const max = Math.min(issuePaths.length, ISSUE_FALLBACK_SCAN_LIMIT) + for (let i = 0; i < max; i += CATALOG_READ_CONCURRENCY) { + if (this.targetLookups.length > 0 && foundTargets.size >= this.targetLookups.length) break + const batch = await Promise.all( + issuePaths.slice(i, Math.min(i + CATALOG_READ_CONCURRENCY, max)).map(async (path): Promise => { + try { + const payload = unwrap((await this.reader.readFile(path)).content) + const state = asRecord(payload.state) + const id = str(payload.stateId) ?? str(state?.id) + const name = str(state?.name) + if (!id || !name) return undefined + return { + id, + name, + teamKey: str(asRecord(payload.team)?.key) ?? str(payload.teamKey), + teamName: str(asRecord(payload.team)?.name) ?? str(payload.teamName), + } + } catch { + return undefined + } + }), + ) + for (const record of batch) { + if (!record) continue + byKey.set(`${norm(record.teamKey) || norm(record.teamName)}:${norm(record.name)}:${record.id}`, record) + for (const target of this.targetLookups) { + if (targetMatchesRecord(target, record)) foundTargets.add(targetKey(target)) + } + } + } + return [...byKey.values()] + } + // Resolve a state NAME to a UUID, optionally scoped to a team token (matched // against team_key or team_name). Throws on no match or ambiguity. async resolve(name: string, teamToken: string | undefined): Promise { @@ -198,6 +274,16 @@ class StateCatalog { } } +const targetKey = (target: TargetStateLookup): string => + `${norm(target.teamToken)}:${norm(target.name)}` + +const targetMatchesRecord = (target: TargetStateLookup, record: StateRecord): boolean => { + if (norm(record.name) !== norm(target.name)) return false + if (!target.teamToken) return true + const teamless = !record.teamKey && !record.teamName + return teamless || norm(record.teamKey) === norm(target.teamToken) || norm(record.teamName) === norm(target.teamToken) +} + // Resolve every configured role for every known team to concrete UUIDs, building // the forward (team,role)->id map and the reverse id->role map. Throws if a // required role cannot be resolved for any team. @@ -205,7 +291,6 @@ export async function resolveFactoryStates( reader: LinearStateReader, input: ResolveFactoryStatesInput, ): Promise { - const catalog = new StateCatalog(reader) const globalNames = input.states ?? {} const explicitIds = input.stateIds ?? {} // Normalize statesByTeam keys up front so per-team lookups are case-insensitive @@ -216,6 +301,15 @@ export async function resolveFactoryStates( byTeamNames[norm(team)] = names } + // Dedupe team tokens by normalized form while keeping the first original + // casing for resolution + error messages. + const teamTokenByNorm = new Map() + for (const team of [...Object.keys(input.statesByTeam ?? {}), ...(input.teams ?? [])]) { + const key = norm(team) + if (key && !teamTokenByNorm.has(key)) teamTokenByNorm.set(key, team) + } + const catalog = new StateCatalog(reader, targetStateLookups(globalNames, byTeamNames, [...teamTokenByNorm.values()])) + // Resolve one role for one team token, applying precedence: // per-team name > global name > explicit UUID. When a name is configured but // the states catalog is unavailable (e.g. /linear/states unreadable under the @@ -239,24 +333,19 @@ export async function resolveFactoryStates( if (explicitIds[role]) return explicitIds[role] // Best-effort team-less default pass: another team will fill this role. if (tolerant) return undefined - // An OPTIONAL role (e.g. humanReview) tolerates an *absent* catalog - // (no /linear/states under the mount token) — but a real resolution - // failure (ambiguous name, cross-team, no match) must still surface, even - // for optional roles, rather than silently disabling the role. - if (!REQUIRED_ROLES.includes(role) && isCatalogUnavailable(error)) return undefined + // An OPTIONAL role (e.g. humanReview) tolerates an absent catalog, or an + // incomplete issue-derived fallback catalog. A healthy /linear/states + // catalog still fails on missing/typoed optional names. + if (!REQUIRED_ROLES.includes(role)) { + if (isCatalogUnavailable(error)) return undefined + if (catalog.isFallback && !isAmbiguousStateError(error)) return undefined + } throw error } } return explicitIds[role] } - // Dedupe team tokens by normalized form while keeping the first original - // casing for resolution + error messages. - const teamTokenByNorm = new Map() - for (const team of [...Object.keys(input.statesByTeam ?? {}), ...(input.teams ?? [])]) { - const key = norm(team) - if (key && !teamTokenByNorm.has(key)) teamTokenByNorm.set(key, team) - } const byTeam = new Map() const resolveAllRoles = async ( @@ -347,3 +436,38 @@ export async function resolveFactoryStates( }, } } + +const isAmbiguousStateError = (error: unknown): boolean => + /ambiguous/u.test(error instanceof Error ? error.message : String(error)) + +const targetStateLookups = ( + globalNames: RoleNames, + byTeamNames: Record, + teamTokens: readonly string[], +): TargetStateLookup[] => { + const byKey = new Map() + const record = (teamToken: string | undefined, names: RoleNames): void => { + for (const role of FACTORY_STATE_ROLES) { + const name = names[role] + if (!name) continue + const target = { name, teamToken } + byKey.set(targetKey(target), target) + } + } + + if (teamTokens.length === 0) { + record(undefined, globalNames) + return [...byKey.values()] + } + + for (const teamToken of teamTokens) { + const teamNames = byTeamNames[norm(teamToken)] ?? {} + for (const role of FACTORY_STATE_ROLES) { + const name = teamNames[role] ?? globalNames[role] + if (!name) continue + const target = { name, teamToken } + byKey.set(targetKey(target), target) + } + } + return [...byKey.values()] +} diff --git a/src/mount/relayfile-cloud-mount-client.test.ts b/src/mount/relayfile-cloud-mount-client.test.ts index 275e74a..4e4bfa2 100644 --- a/src/mount/relayfile-cloud-mount-client.test.ts +++ b/src/mount/relayfile-cloud-mount-client.test.ts @@ -218,7 +218,10 @@ describe('RelayfileCloudMountClient', () => { const joinOptions = joinCalls[0][1] expect(joinOptions.scopes).not.toContain('relayfile:fs:read:/**') expect(joinOptions.scopes).not.toContain('relayfile:fs:write:/**') + expect(joinOptions.scopes).toContain('relayfile:fs:read:/linear/states/**') expect(joinOptions.scopes).toContain('relayfile:fs:read:/slack/users/**') + expect(factoryReadScopeCovers('/linear/states/_index.json')).toBe(true) + expect(factoryReadScopeCovers('/linear/states/state-ready.json')).toBe(true) expect(factoryReadScopeCovers('/slack/users/U123/messages/1781267200_000000/meta.json')).toBe(true) expect(capturedTokenProvider).toBeDefined() await expect(capturedTokenProvider?.()).resolves.toBe('cld_at_shared') diff --git a/src/mount/relayfile-cloud-mount-client.ts b/src/mount/relayfile-cloud-mount-client.ts index 77f4340..7b62a89 100644 --- a/src/mount/relayfile-cloud-mount-client.ts +++ b/src/mount/relayfile-cloud-mount-client.ts @@ -34,6 +34,7 @@ const DEFAULT_WORKSPACE_ID = 'rw_7ccfea89' const DEFAULT_AGENT_NAME = 'agent-relay-factory' export const FACTORY_RELAYFILE_SCOPES = [ 'relayfile:fs:read:/linear/issues/**', + 'relayfile:fs:read:/linear/states/**', 'relayfile:fs:write:/linear/issues/**', 'relayfile:fs:read:/github/repos/**', 'relayfile:fs:read:/slack/channels/**',