Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/linear/state-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ function makeReader(
}
}

function makeIssueFallbackReader(
issues: Record<string, { stateId?: string; state?: { id?: string; name?: string }; team?: { key?: string; name?: string } }>,
): 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' },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, { stateId?: string; state?: { id?: string; name?: string }; team?: { key?: string; name?: string } }> = {
'/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<string, { stateId?: string; state?: { id?: string; name?: string }; team?: { key?: string; name?: string } }> = {}
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)
})
})
152 changes: 138 additions & 14 deletions src/linear/state-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>
}

type RoleNames = Partial<Record<FactoryStateRole, string>>
Expand Down Expand Up @@ -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[] => {
Expand All @@ -119,6 +126,11 @@ const unwrap = (value: unknown): Record<string, unknown> => {
: record
}

const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: 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
Expand All @@ -133,14 +145,29 @@ 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<StateRecord[]> {
if (this.#records) return this.#records
let index: unknown
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
}
Comment on lines +165 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Set Fallback Flag

Mark the catalog as fallback when we successfully reconstruct it from issues.

      const issueRecords = await this.recordsFromIssues(this.targetNames)
      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)}). ` +
Expand Down Expand Up @@ -170,6 +197,55 @@ class StateCatalog {
return records
}

async recordsFromIssues(): Promise<StateRecord[]> {
if (!this.reader.listTree) return []
let paths: string[]
try {
paths = await this.reader.listTree('/linear/issues')
} catch {
return []
}

const byKey = new Map<string, StateRecord>()
const foundTargets = new Set<string>()
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<StateRecord | undefined> => {
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve top-level state_name in issue fallback

When /linear/states/_index.json is absent and the fallback harvests state records from /linear/issues, synced issues that carry stateId/state.id plus top-level state_name are ignored because name only reads state.name. That sparse shape is already accepted by parseLinearIssue, so these mounts still fail startup with missing roles even though each issue contains the state id/name needed for resolution; include payload.state_name as a fallback here.

Useful? React with 👍 / 👎.

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()]
}
Comment on lines +200 to +247

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Performance Bottleneck in Issue Fallback Scanning

Reading every single issue file sequentially in batches of 10 over a remote/cloud mount can cause severe performance degradation, startup timeouts, or rate limits if the workspace contains thousands of issues.

To optimize this, we can:

  1. Pass the set of configured target state names to StateCatalog and stop scanning issues early as soon as we have found a state ID for every target name.
  2. Cap the maximum number of issues scanned (e.g., 150) to prevent scanning the entire history if a configured state is never used in any issue.
  async recordsFromIssues(targetNames?: Set<string>): Promise<StateRecord[]> { 
    if (!this.reader.listTree) return []
    let paths: string[]
    try {
      paths = await this.reader.listTree('/linear/issues')
    } catch {
      return []
    }

    const byKey = new Map<string, StateRecord>()
    const issuePaths = paths.filter((path) =>
      path.endsWith('.json') &&
      !path.includes('/comments/') &&
      !path.startsWith('/linear/issues/by-uuid/'),
    )
    const foundNames = new Set<string>()
    const maxIssuesToScan = 150
    for (let i = 0; i < issuePaths.length && i < maxIssuesToScan; i += CATALOG_READ_CONCURRENCY) {
      if (targetNames && targetNames.size > 0 && foundNames.size >= targetNames.size) {
        break
      }
      const batch = await Promise.all(
        issuePaths.slice(i, i + CATALOG_READ_CONCURRENCY).map(async (path): Promise<StateRecord | undefined> => {
          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)
        if (record.name) {
          foundNames.add(norm(record.name))
        }
      }
    }
    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<string> {
Expand Down Expand Up @@ -198,14 +274,23 @@ 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.
export async function resolveFactoryStates(
reader: LinearStateReader,
input: ResolveFactoryStatesInput,
): Promise<FactoryStateResolution> {
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
Expand All @@ -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<string, string>()
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
Expand All @@ -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<string, string>()
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<string, RoleIds>()

const resolveAllRoles = async (
Expand Down Expand Up @@ -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<string, RoleNames>,
teamTokens: readonly string[],
): TargetStateLookup[] => {
const byKey = new Map<string, TargetStateLookup>()
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()]
}
3 changes: 3 additions & 0 deletions src/mount/relayfile-cloud-mount-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions src/mount/relayfile-cloud-mount-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/**',
Expand Down