Skip to content
Open
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
659 changes: 653 additions & 6 deletions src/cli/fleet.test.ts

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ export type {
Clock,
EventPage,
GithubConnectionWrite,
GithubConnectionMutationReceipt,
GithubConnectionIssue,
GithubConnectionRead,
GithubIssueLookup,
Expand All @@ -352,7 +353,11 @@ export type {
SpawnResult,
AgentUsage,
GithubRead,
GithubIssueCloseWriteResult,
GithubIssueStatus,
GithubStatusWriteResult,
GithubStatusClaimReceipt,
GithubStatusRollbackResult,
GithubWriteback,
LinearWriteback,
Logger,
Expand Down
81 changes: 80 additions & 1 deletion src/mount/relayfile-cloud-mount-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,25 @@ describe('RelayfileCloudMountClient', () => {
}
}

class HangingReadFileClient extends FakeRelayFileClient {
seenSignal?: AbortSignal

override async readFile(
workspaceId: string,
path: string,
_correlationId?: string,
signal?: AbortSignal,
): Promise<never> {
this.readFileCalls.push({ workspaceId, path })
this.seenSignal = signal
return await new Promise<never>((_resolve, reject) => {
signal?.addEventListener('abort', () => {
reject((signal as AbortSignal & { reason?: unknown }).reason)
})
})
}
}

it('cancels a read that stops answering and names the operation', async () => {
const client = new HangingListTreeClient()
const mount = new RelayfileCloudMountClient({
Expand Down Expand Up @@ -1546,6 +1565,38 @@ describe('RelayfileCloudMountClient', () => {
})
})

it('cancels the revision read before an unguarded write', async () => {
const client = new HangingReadFileClient()
const mount = new RelayfileCloudMountClient({
workspaceId: 'rw_test',
client,
operationTimeoutMs: 25,
})

await expect(mount.writeFile('/tmp/draft.json', { draft: true })).rejects.toMatchObject({
name: 'RelayfileOperationTimeoutError',
operation: 'writeFile.readRevision',
})
expect(client.seenSignal?.aborted).toBe(true)
expect(client.writeFileCalls).toEqual([])
})

it('cancels the current-revision read before a delete', async () => {
const client = new HangingReadFileClient()
const mount = new RelayfileCloudMountClient({
workspaceId: 'rw_test',
client,
operationTimeoutMs: 25,
})

await expect(mount.deleteFile('/tmp/draft.json')).rejects.toMatchObject({
name: 'RelayfileOperationTimeoutError',
operation: 'deleteFile.readCurrent',
})
expect(client.seenSignal?.aborted).toBe(true)
expect(client.deleteFileCalls).toEqual([])
})

it('caps an explicit ensureSubRoot timeout at the tighter client-wide budget', async () => {
const client = new HangingListTreeClient()
const mount = new RelayfileCloudMountClient({
Expand Down Expand Up @@ -1766,7 +1817,8 @@ describe('RelayfileCloudMountClient', () => {
})
const mount = new RelayfileCloudMountClient({ workspaceId: 'rw_test', client: fake, isAllowedDraft: () => true })

await mount.writeFile('/linear/issues/AR-1.json', { stateId: 'new' })
await expect(mount.writeFile('/linear/issues/AR-1.json', { stateId: 'new' }))
.resolves.toEqual({ targetRevision: 'next' })

expect(fake.writeFileCalls).toEqual([{
workspaceId: 'rw_test',
Expand All @@ -1777,6 +1829,33 @@ describe('RelayfileCloudMountClient', () => {
}])
})

it('does not refresh or retry an explicit baseRevision after a conflict', async () => {
const fake = new FakeRelayFileClient()
const conflict = Object.assign(new Error('revision conflict'), { status: 409 })
const write = vi.spyOn(fake, 'writeFile').mockRejectedValue(conflict)
const mount = new RelayfileCloudMountClient({
workspaceId: 'rw_test',
client: fake,
isAllowedDraft: () => true,
})

await expect(mount.writeFile(
'/linear/issues/AR-1.json',
{ stateId: 'ready' },
{ baseRevision: '7' },
)).rejects.toBe(conflict)

expect(write).toHaveBeenCalledTimes(1)
expect(write).toHaveBeenCalledWith({
workspaceId: 'rw_test',
path: '/linear/issues/AR-1.json',
baseRevision: '7',
content: '{"stateId":"ready"}',
contentType: 'application/json',
})
expect(fake.readFileCalls).toEqual([])
})

it('uses baseRevision 0 for creates and confirms the queued operation', async () => {
const fake = new FakeRelayFileClient()
const mount = new RelayfileCloudMountClient({
Expand Down
32 changes: 25 additions & 7 deletions src/mount/relayfile-cloud-mount-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,11 @@ export class RelayfileCloudMountClient implements MountClient {
}
}

async writeFile(path: string, content: unknown, opts?: { guarded?: boolean }): Promise<void> {
async writeFile(
path: string,
content: unknown,
opts?: { guarded?: boolean; baseRevision?: string },
): Promise<{ targetRevision: string }> {
if (isProviderWritebackPath(path) && await this.#isAllowedDraft?.(path, content, opts) !== true) {
throw new Error(`Refusing provider writeback draft for ${path}: draft predicate rejected or is unset`)
}
Expand All @@ -691,9 +695,19 @@ export class RelayfileCloudMountClient implements MountClient {
this.#confirmedFailureReasonByPath.delete(path)

const writeAtCurrentRevision = async (): Promise<WriteQueuedResponse> => {
let baseRevision = '0'
let baseRevision = opts?.baseRevision ?? '0'
if (opts?.baseRevision !== undefined) {
return this.#client.writeFile({
workspaceId: this.workspaceId,
path,
baseRevision,
content: serialized.content,
contentType: serialized.contentType,
})
}
try {
baseRevision = (await this.#client.readFile(this.workspaceId, path)).revision
baseRevision = (await this.#bounded('writeFile.readRevision', this.#operationTimeoutMs, (signal) =>
this.#client.readFile(this.workspaceId, path, undefined, signal))).revision
} catch (error) {
if (!isHttpStatus(error, 404)) throw error
}
Expand All @@ -707,18 +721,22 @@ export class RelayfileCloudMountClient implements MountClient {
})
}

let queued: WriteQueuedResponse
try {
this.#lastOpByPath.set(path, (await writeAtCurrentRevision()).opId)
queued = await writeAtCurrentRevision()
} catch (error) {
if (!isHttpStatus(error, 409)) throw error
this.#lastOpByPath.set(path, (await writeAtCurrentRevision()).opId)
if (opts?.baseRevision !== undefined || !isHttpStatus(error, 409)) throw error
queued = await writeAtCurrentRevision()
}
this.#lastOpByPath.set(path, queued.opId)
return { targetRevision: queued.targetRevision }
}

async deleteFile(path: string): Promise<void> {
this.#confirmedExternalIdByPath.delete(path)
this.#confirmedFailureReasonByPath.delete(path)
const current = await this.#client.readFile(this.workspaceId, path)
const current = await this.#bounded('deleteFile.readCurrent', this.#operationTimeoutMs, (signal) =>
this.#client.readFile(this.workspaceId, path, undefined, signal))
const currentContent = parseRemoteContent(current)
if (isProviderPath(path)) {
await this.#assertProviderDeleteAllowed(path, currentContent)
Expand Down
20 changes: 18 additions & 2 deletions src/mount/relayfile-github-connection-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ const gitRunnerForBranch = (branch: string): GitCommandRunner => vi.fn(async (ar
})

describe('RelayfileGithubConnectionWrite', () => {
it('reads a private issue through the authenticated connected projection', async () => {
const path = '/github/repos/PrivateOrg__private-repo/issues/by-id/42.json'
const content = { payload: { number: 42, labels: [{ name: 'factory:human-review' }] } }
const write = new RelayfileGithubConnectionWrite({
mount: new FakeMountClient({ [path]: content }),
})

await expect(write.getIssue('PrivateOrg/private-repo', 42)).resolves.toEqual({
outcome: 'found',
issue: { repo: 'PrivateOrg/private-repo', number: 42, path, content },
})
})

it('publishes an already-pushed remote branch without reading an orchestrator-local clone', async () => {
const pullRequestPath = '/github/repos/AgentWorkforce/factory/pull-requests/factory-factory-ar-85-agentworkforce-factory-pushed.json'
class ReceiptMount extends FakeMountClient {
Expand Down Expand Up @@ -234,21 +247,24 @@ describe('RelayfileGithubConnectionWrite', () => {
description: 'Factory agents are working on this issue.',
author: 'app',
})
await write.mutateIssueLabel({
const addReceipt = await write.mutateIssueLabel({
repo: 'AgentWorkforce/factory',
number: 221,
operation: 'add',
label: 'factory:in-progress',
author: 'app',
})
await write.mutateIssueLabel({
const removeReceipt = await write.mutateIssueLabel({
repo: 'AgentWorkforce/factory',
number: 221,
operation: 'remove',
label: 'factory:human-review',
author: 'app',
})

expect(addReceipt).toBe('acknowledged')
expect(removeReceipt).toBe('acknowledged')

expect(mount.writes).toEqual([
{
path: '/github/repos/AgentWorkforce/factory/labels/factory-11111111-1111-4111-8111-111111111111.json',
Expand Down
37 changes: 36 additions & 1 deletion src/mount/relayfile-github-connection-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
} from '../github/writeback-paths'
import type {
GithubConnectionIssueUpdateInput,
GithubIssueLookup,
GithubConnectionMutationReceipt,
GithubConnectionWrite,
GithubPublishPullRequestInput,
GithubPublishPullRequestResult,
Expand Down Expand Up @@ -50,6 +52,35 @@ export class RelayfileGithubConnectionWrite implements GithubConnectionWrite {
this.#operationIdFactory = config.operationIdFactory ?? randomUUID
}

async getIssue(repo: string, number: number): Promise<GithubIssueLookup> {
const { owner, repo: name } = githubRepoParts(repo)
assertPositiveGithubNumber(number, 'issue')
// Provider projections use the encoded owner__repo canonical tree, while
// connected write paths use the nested owner/repo tree. Accept both so the
// authenticated workspace connection remains authoritative across mount
// layouts and migrations.
const paths = [
`/github/repos/${encodeURIComponent(owner)}__${encodeURIComponent(name)}/issues/by-id/${number}.json`,
`/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/by-id/${number}.json`,
]
for (const path of paths) {
try {
const { content } = await this.#mount.readFile(path)
return {
outcome: 'found',
issue: { repo: `${owner}/${name}`, number, path, content },
Comment thread
khaliqgant marked this conversation as resolved.
}
} catch {
// Try the alternate canonical layout. If neither is readable, absence
// and transient sync failure are intentionally indistinguishable.
}
}
return {
outcome: 'indeterminate',
reason: `connected GitHub projection did not expose ${owner}/${name}#${number}`,
}
}

async publishPullRequest(input: GithubPublishPullRequestInput): Promise<GithubPublishPullRequestResult> {
const { owner, repo } = githubRepoParts(input.repo)
const headRef = input.headRef ?? (input.clonePath
Expand Down Expand Up @@ -188,7 +219,7 @@ export class RelayfileGithubConnectionWrite implements GithubConnectionWrite {
operation: 'add' | 'remove'
label: string
author: 'app'
}): Promise<void> {
}): Promise<GithubConnectionMutationReceipt> {
const repoRoot = githubRepoRoot(input.repo)
assertPositiveGithubNumber(input.number, 'issue')
assertAppAuthor(input.author, 'issue label mutations')
Expand All @@ -199,6 +230,10 @@ export class RelayfileGithubConnectionWrite implements GithubConnectionWrite {
? { operation: 'add', labels: [label] }
: { operation: 'remove', label },
)
// The durable operation proves App authorship and provider success, but
// the current adapter receipt does not distinguish a created mutation from
// an idempotent no-op. Callers must not infer ownership from it.
return 'acknowledged'
}

async updateIssue(input: GithubConnectionIssueUpdateInput): Promise<void> {
Expand Down
Loading
Loading