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
22 changes: 20 additions & 2 deletions desktop/src/main/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,18 @@ describe('normalizeServerUrl', () => {
describe('testCloudConnection', () => {
it('reports healthy authenticated servers, install support, and health version', async () => {
const fetchImpl = fakeFetch([
json({ status: 'healthy', version: '1.2.3' }),
json({ status: 'healthy', version: '1.2.3', furrow_public_addr: 'furrow.example:8802' }),
json({ packages: [] }),
json([])
])
await expect(
testCloudConnection('cp.example/', 'secret', { fetchImpl })
testCloudConnection('cp.example/', 'secret', { fetchImpl, furrowProbe: async () => true })
).resolves.toEqual({
ok: true,
healthy: true,
authOk: true,
installApi: true,
furrowAvailable: true,
version: '1.2.3',
message: 'Connection successful'
})
Expand All @@ -76,6 +77,7 @@ describe('testCloudConnection', () => {
healthy: true,
authOk: false,
installApi: false,
furrowAvailable: false,
message: 'API key rejected'
})
})
Expand All @@ -87,6 +89,7 @@ describe('testCloudConnection', () => {
healthy: false,
authOk: false,
installApi: false,
furrowAvailable: false,
message: 'Could not reach https://cp.example'
})
})
Expand All @@ -105,6 +108,7 @@ describe('testCloudConnection', () => {
healthy: true,
authOk: true,
installApi: false,
furrowAvailable: false,
version: '0.9.0',
message: 'Connected; install API unavailable'
})
Expand All @@ -121,6 +125,20 @@ describe('testCloudConnection', () => {
expect(result.ok).toBe(true)
expect(result).not.toHaveProperty('version')
})

it('keeps a healthy connection when the advertised furrow port is unreachable', async () => {
const fetchImpl = fakeFetch([
json({ status: 'healthy', furrow_public_addr: 'furrow.example:8802' }),
json({ packages: [] }),
json([]),
json({}, 404)
])
const result = await testCloudConnection('https://cp.example', 'key', {
fetchImpl,
furrowProbe: async () => false
})
expect(result).toMatchObject({ ok: true, healthy: true, authOk: true, furrowAvailable: false })
})
})

describe('applyConnectionProfile', () => {
Expand Down
67 changes: 66 additions & 1 deletion desktop/src/main/cloud.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CloudTestResult, DesktopSettings } from '../shared/types'
import { connect as netConnect } from 'node:net'
import { clearCloudConnection, setCloudConnection, setLocalApiKey } from './connection'

export type { CloudTestResult } from '../shared/types'
Expand Down Expand Up @@ -51,6 +52,54 @@ function versionFrom(value: unknown): string | undefined {
: undefined
}

function furrowAddressFrom(value: unknown): string | undefined {
const body = record(value)
if (!body) return undefined
for (const key of ['furrow_public_addr', 'furrowPublicAddr', 'FURROW_PUBLIC_ADDR']) {
if (typeof body[key] === 'string' && body[key] !== '') return body[key] as string
}
const furrow = record(body.furrow)
if (!furrow) return undefined
for (const key of ['public_addr', 'publicAddr', 'address']) {
if (typeof furrow[key] === 'string' && furrow[key] !== '') return furrow[key] as string
}
return undefined
}

function probeFurrow(address: string): Promise<boolean> {
return new Promise((resolve) => {
let parsed: URL
try {
parsed = new URL(`tls://${address}`)
} catch {
resolve(false)
return
}
const port = Number(parsed.port)
if (!parsed.hostname || !Number.isInteger(port) || port <= 0 || port > 65535) {
resolve(false)
return
}
let settled = false
const finish = (available: boolean): void => {
if (settled) return
settled = true
socket.destroy()
resolve(available)
}
// Reachability only. A TLS handshake here would have to skip certificate
// validation, since furrowd's default certificate is self-signed — and it
// would answer no more than a plain connect does. What actually protects
// the workspace is furrow's own payload encryption plus the per-run token,
// neither of which this probe touches.
const socket = netConnect({ host: parsed.hostname, port })
socket.setTimeout(1500)
socket.once('connect', () => finish(true))
socket.once('timeout', () => finish(false))
socket.once('error', () => finish(false))
})
}

async function jsonBestEffort(response: Response): Promise<unknown> {
try {
return await response.json()
Expand All @@ -62,7 +111,7 @@ async function jsonBestEffort(response: Response): Promise<unknown> {
export async function testCloudConnection(
url: string,
apiKey: string,
deps: { fetchImpl?: typeof fetch } = {}
deps: { fetchImpl?: typeof fetch; furrowProbe?: (address: string) => Promise<boolean> } = {}
): Promise<CloudTestResult> {
const normalized = normalizeServerUrl(url)
if (!normalized) {
Expand All @@ -71,6 +120,7 @@ export async function testCloudConnection(
healthy: false,
authOk: false,
installApi: false,
furrowAvailable: false,
message: 'Enter a valid server URL'
}
}
Expand All @@ -90,19 +140,22 @@ export async function testCloudConnection(
healthy: false,
authOk: false,
installApi: false,
furrowAvailable: false,
message: `Could not reach ${normalized}`
}
}

const healthBody = await jsonBestEffort(health)
const healthy = health.ok
let version = versionFrom(healthBody)
const furrowAddress = furrowAddressFrom(healthBody)
if (!healthy) {
return {
ok: false,
healthy: false,
authOk: false,
installApi: false,
furrowAvailable: false,
...(version ? { version } : {}),
message: `Control plane at ${normalized} is not healthy`
}
Expand All @@ -121,6 +174,7 @@ export async function testCloudConnection(
healthy: true,
authOk: false,
installApi: false,
furrowAvailable: false,
...(version ? { version } : {}),
message: 'Could not verify API access'
}
Expand All @@ -132,6 +186,7 @@ export async function testCloudConnection(
healthy: true,
authOk: false,
installApi: false,
furrowAvailable: false,
...(version ? { version } : {}),
message:
authResponse.status === 401 || authResponse.status === 403
Expand Down Expand Up @@ -164,11 +219,21 @@ export async function testCloudConnection(
}
}

let furrowAvailable = false
if (furrowAddress) {
try {
furrowAvailable = await (deps.furrowProbe ?? probeFurrow)(furrowAddress)
} catch {
furrowAvailable = false
}
}

return {
ok: true,
healthy: true,
authOk: true,
installApi,
furrowAvailable,
...(version ? { version } : {}),
message: installApi ? 'Connection successful' : 'Connected; install API unavailable'
}
Expand Down
49 changes: 47 additions & 2 deletions desktop/src/main/deployEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ function outputs(overrides: Record<string, unknown> = {}) {
project_id: { value: 'project' },
environment_id: { value: 'environment' },
service_id: { value: 'service' },
furrow_domain: { value: 'furrow.proxy.test' },
furrow_port: { value: 12345 },
...overrides
})
}
Expand All @@ -61,6 +63,43 @@ function deployedState(apiKey = 'prior-key', subdomain = 'agentfield-dead') {
}

describe('deployment module and execution', () => {
// Workspace sync is an extra. A control plane that is up and reachable is a
// successful deploy whether or not a furrow address came back with it, so a
// missing proxy output must never turn into a failed deployment.
it('still succeeds when the deployment reports no furrow address', async () => {
const plain = workspace()
const fake = harness([
{},
{ stdout: '{"type":"apply_complete","@message":"Apply complete"}\n' },
{ stdout: outputs({ furrow_domain: undefined, furrow_port: undefined }) }
])
const result = await runDeploy(plain.opts, fake.deps)
expect(result).toMatchObject({ ok: true, url: 'https://cp.test', apiKey: 'key' })
expect((result as { furrowAddress?: string }).furrowAddress).toBeUndefined()
})

// The Railway provider hands back the proxy domain as an absolute FQDN on
// create ("altaria.proxy.rlwy.net.") but without the trailing dot on refresh.
// Interpolating it raw made the very next deploy rewrite FURROW_PUBLIC_ADDR,
// and a changed service variable restarts the control plane — so a re-deploy
// that should have been a no-op bounced the server. Normalising both places
// the domain is read keeps the published address identical across applies.
it('normalises the proxy domain so redeploys do not rewrite the address', async () => {
const plain = workspace()
const fake = harness([
{},
{ stdout: '{"type":"apply_complete","@message":"Apply complete"}\n' },
{ stdout: outputs({}) }
])
await runDeploy(plain.opts, fake.deps)
const module = readFileSync(join(plain.opts.workspaceDir, 'main.tf'), 'utf8')
for (const line of module.split('\n')) {
if (!line.includes('railway_tcp_proxy.furrow.domain')) continue
expect(line).toContain('trimsuffix(railway_tcp_proxy.furrow.domain, ".")')
}
expect(module).toMatch(/railway_tcp_proxy\.furrow\.domain/)
})

it('writes the module and a CLI mirror config only when a mirror exists', async () => {
const withMirror = workspace(true)
const fake = harness([
Expand All @@ -69,12 +108,14 @@ describe('deployment module and execution', () => {
{ stdout: outputs() }
])
const result = await runDeploy(withMirror.opts, fake.deps)
expect(result).toMatchObject({ ok: true, url: 'https://cp.test', apiKey: 'key' })
expect(result).toMatchObject({ ok: true, url: 'https://cp.test', apiKey: 'key', furrowAddress: 'furrow.proxy.test:12345' })
const module = readFileSync(join(withMirror.opts.workspaceDir, 'main.tf'), 'utf8')
expect(module).toContain('resource "railway_project" "cp"')
expect(module).toContain('workspace_id = var.workspace_id')
expect(module).toContain('source_image = "agentfield/control-plane-cloud:latest"')
expect(module).not.toMatch(/\bvolume\s*=/)
expect(module).toMatch(/resource "railway_tcp_proxy" "furrow" \{[\s\S]*?application_port = 8802[\s\S]*?environment_id\s*= railway_project\.cp\.default_environment\.id[\s\S]*?service_id\s*= railway_service\.cp\.id[\s\S]*?\}/)
expect(module).toMatch(/resource "railway_variable" "furrow_public_addr" \{[\s\S]*?name\s*= "FURROW_PUBLIC_ADDR"[\s\S]*?value\s*= "\$\{trimsuffix\(railway_tcp_proxy\.furrow\.domain, "\."\)\}:\$\{railway_tcp_proxy\.furrow\.proxy_port\}"[\s\S]*?\}/)
expect(module).toContain('output "project_id"')
expect(module).toContain('output "environment_id"')
expect(module).toContain('output "service_id"')
Expand Down Expand Up @@ -145,7 +186,11 @@ describe('deployment module and execution', () => {
const fake = harness([{}, {}, { stdout: outputs() }], fetchImpl)

expect(await runDeploy({ ...fixture.opts, onLine: (line) => lines.push(line) }, fake.deps)).toEqual({
ok: true, url: 'https://cp.test', apiKey: 'key', message: 'AgentField deployed to Railway.'
ok: true,
url: 'https://cp.test',
apiKey: 'key',
furrowAddress: 'furrow.proxy.test:12345',
message: 'AgentField deployed to Railway.'
})
expect(fetchImpl).toHaveBeenCalledTimes(2)
const requests = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls as unknown as Array<[string, RequestInit]>
Expand Down
26 changes: 25 additions & 1 deletion desktop/src/main/deployEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface DeployResult {
ok: boolean
url?: string
apiKey?: string
furrowAddress?: string
message: string
}

Expand Down Expand Up @@ -71,7 +72,20 @@ resource "railway_service_domain" "cp" {
environment_id = railway_project.cp.default_environment.id
service_id = railway_service.cp.id
}
resource "railway_tcp_proxy" "furrow" {
application_port = 8802
environment_id = railway_project.cp.default_environment.id
service_id = railway_service.cp.id
}
resource "railway_variable" "furrow_public_addr" {
name = "FURROW_PUBLIC_ADDR"
value = "\${trimsuffix(railway_tcp_proxy.furrow.domain, ".")}:\${railway_tcp_proxy.furrow.proxy_port}"
environment_id = railway_project.cp.default_environment.id
service_id = railway_service.cp.id
}
output "url" { value = "https://\${railway_service_domain.cp.domain}" }
output "furrow_domain" { value = trimsuffix(railway_tcp_proxy.furrow.domain, ".") }
output "furrow_port" { value = railway_tcp_proxy.furrow.proxy_port }
output "project_id" { value = railway_project.cp.id }
output "environment_id" { value = railway_project.cp.default_environment.id }
output "service_id" { value = railway_service.cp.id }
Expand Down Expand Up @@ -322,9 +336,19 @@ export async function runDeploy(opts: DeployEngineOptions, deps: DeploySpawnDeps
const projectId = values.project_id?.value
const environmentId = values.environment_id?.value
const serviceId = values.service_id?.value
const furrowDomain = values.furrow_domain?.value
const furrowPort = values.furrow_port?.value
if (typeof url !== 'string' || !url || typeof outputKey !== 'string' || !outputKey ||
typeof projectId !== 'string' || !projectId || typeof environmentId !== 'string' || !environmentId ||
typeof serviceId !== 'string' || !serviceId) throw new Error('missing')
// Workspace sync is an extra, so its outputs are read separately and never
// gate the deploy: a control plane that is up and reachable is a success
// even when no furrow address came back with it.
const furrowAddress =
typeof furrowDomain === 'string' && furrowDomain &&
typeof furrowPort === 'number' && Number.isInteger(furrowPort) && furrowPort > 0
? `${furrowDomain}:${furrowPort}`
: undefined
opts.onLine?.('Attaching storage volume…')
try {
await ensureVolume(opts.railwayToken, projectId, environmentId, serviceId, deps.fetchImpl ?? fetch)
Expand All @@ -333,7 +357,7 @@ export async function runDeploy(opts: DeployEngineOptions, deps: DeploySpawnDeps
return { ok: false, message: `Deployed, but attaching the storage volume failed: ${detail}. Re-run deploy to retry.` }
}
opts.onLine?.('Storage volume ready')
return { ok: true, url, apiKey: outputKey, message: 'AgentField deployed to Railway.' }
return { ok: true, url, apiKey: outputKey, furrowAddress, message: 'AgentField deployed to Railway.' }
} catch {
return { ok: false, message: 'Deployment completed, but required outputs are missing.' }
}
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ function main(): void {
await saveSettings(settingsFile(), settings)
applyConnectionProfile(settings)
}
return { ok: result.ok, url: result.url, message: result.message }
return { ok: result.ok, url: result.url, furrowAddress: result.furrowAddress, message: result.message }
} finally {
cloudDeployInFlight = false
}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export interface CloudTestResult {
healthy: boolean
authOk: boolean
installApi: boolean
furrowAvailable: boolean
version?: string
message: string
}
Expand All @@ -246,6 +247,7 @@ export interface RailwayStatus {
export interface CloudDeployResult {
ok: boolean
url?: string
furrowAddress?: string
message: string
}

Expand Down
Loading
Loading