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
4 changes: 3 additions & 1 deletion src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {
} from '../index'
import { FakeFleetClient, FakeMountClient } from '../testing'
import { GitAgentWorktreeManager } from '../git/agent-worktree'
import { loadOrCreateFactoryInstanceId } from '../observability/instance-identity'
import { loadOrCreateFactoryInstanceId, resolveFactoryInstanceName } from '../observability/instance-identity'

interface FleetCliDeps {
fleet?: FleetClient
Expand Down Expand Up @@ -890,6 +890,7 @@ async function buildFactoryCloudReporter(input: {
const outboxPath = input.config.reporting.outboxPath
?? join(dirname(input.config.loop.registryPath), 'factory-cloud-events.json')
const instanceId = await loadOrCreateFactoryInstanceId(`${outboxPath}.instance-id`)
const instanceName = resolveFactoryInstanceName(input.config)
const cloudFetch: typeof fetch = async (_request, init) =>
session.client.fetch('/api/v1/factory/events', init)
return new FactoryCloudReporter({
Expand All @@ -899,6 +900,7 @@ async function buildFactoryCloudReporter(input: {
bootId: randomUUID(),
version: await readFactoryVersion(),
metadata: {
...(instanceName !== undefined ? { name: instanceName } : {}),
backend: input.backend,
mode: input.mode,
runtime: 'node',
Expand Down
18 changes: 18 additions & 0 deletions src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ describe('FactoryConfigSchema', () => {
})
})

it('trims and validates an explicit reporting instance name', () => {
const parsed = FactoryConfigSchema.parse({
repos: {},
reporting: { instanceName: ' Oslo Factory ' },
})

expect(parsed.reporting.instanceName).toBe('Oslo Factory')
expect(() => FactoryConfigSchema.parse({
repos: {},
reporting: { instanceName: ' ' },
})).toThrow()
expect(() => FactoryConfigSchema.parse({
repos: {},
reporting: { instanceName: 'x'.repeat(257) },
})).toThrow()
})

it('honors an explicit per-role agent CLI override and rejects unwired capabilities', () => {
const parsed = FactoryConfigSchema.parse({
workspaceId: 'ws_123',
Expand Down Expand Up @@ -201,6 +218,7 @@ describe('FactoryConfigSchema', () => {
expect(parsed.subscription.labels).toEqual(['pear', 'cloud', 'agentswarm'])
expect(parsed.repos.default).toBe('pear')
expect(parsed.repos.org).toBe('AgentWorkforce')
expect(parsed.repos.names).toEqual(['pear', 'cloud', 'agentswarm'])
})

it('lets explicit byLabel/clonePaths/labels override the derived ones', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ const babysitterSchema = z.object({

const reportingSchema = z.object({
enabled: z.boolean().default(true),
instanceName: z.string().trim().min(1).max(256).optional(),
outboxPath: z.string().min(1).optional(),
batchSize: z.number().int().min(1).max(100).default(100),
requestTimeoutMs: z.number().int().min(100).max(60_000).default(15_000),
Expand Down Expand Up @@ -297,6 +298,7 @@ function normalizeFactoryConfig(cfg: z.infer<typeof FactoryConfigObjectSchema>)
},
repos: {
...(cfg.repos.org !== undefined ? { org: cfg.repos.org } : {}),
...(cfg.repos.names !== undefined ? { names: cfg.repos.names } : {}),
byLabel: resolved.byLabel,
byProject: resolved.byProject,
keywordRules: resolved.keywordRules,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export {
FACTORY_CLOUD_EVENT_MAX_BATCH_SIZE,
FACTORY_CLOUD_EVENT_MAX_PAYLOAD_BYTES,
FACTORY_CLOUD_EVENT_TYPES,
FACTORY_CLOUD_CANCELLATION_REASONS_V1,
FACTORY_CLOUD_RELEASE_REASONS_V1,
FactoryCloudEventAttributesV1Schema,
FactoryCloudEventBatchV1Schema,
Expand Down Expand Up @@ -284,6 +285,7 @@ export type {
} from './observability/cloud-reporter'
export type {
CreateFactoryCloudEventV1Options,
FactoryCloudCancellationReasonV1,
FactoryCloudEventAttributesV1,
FactoryCloudEventBatchV1,
FactoryCloudEventInputV1,
Expand Down
54 changes: 53 additions & 1 deletion src/observability/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ describe('Factory cloud event v1 contract', () => {

it('maps raw fleet exit reasons to a closed privacy-safe category', () => {
expect(factoryCloudReleaseReasonV1('node-offline')).toBe('node_offline')
expect(factoryCloudReleaseReasonV1('worker_exited')).toBe('exited')
expect(factoryCloudReleaseReasonV1('max delivery retries exceeded')).toBe('delivery_failed')
expect(factoryCloudReleaseReasonV1('live dispatch state changed')).toBe('source_state_changed')
expect(factoryCloudReleaseReasonV1('customer path /private/repo failed')).toBe('other')
expect(factoryCloudReleaseReasonV1(undefined)).toBeUndefined()

Expand All @@ -99,6 +101,24 @@ describe('Factory cloud event v1 contract', () => {
})).toThrow()
})

it('accepts only closed cancellation reasons', () => {
const cancelled = createFactoryCloudEventV1({
type: 'run.cancelled',
runId: 'run-cancelled',
status: 'cancelled',
attributes: { cancellationReason: 'agent_delivery_failed' },
})
expect(cancelled.attributes?.cancellationReason).toBe('agent_delivery_failed')

expect(() => FactoryCloudEventInputV1Schema.parse({
id: 'event-private-cancellation',
occurredAt: '2026-07-19T12:00:00.000Z',
type: 'run.cancelled',
runId: 'run-private-cancellation',
attributes: { cancellationReason: 'recipient unavailable: /private/customer/repo' },
})).toThrow()
})

it('rejects fields that could carry prompts, messages, paths, URLs, or raw errors', () => {
const base = {
id: 'event-private',
Expand All @@ -118,7 +138,12 @@ describe('Factory cloud event v1 contract', () => {
it('validates the exact authenticated ingestion batch shape', () => {
const parsed = FactoryCloudEventBatchV1Schema.parse({
contract: 'factory.telemetry.v1',
instance: { id: 'instance-1', bootId: 'boot-1', version: '0.1.32' },
instance: {
id: 'instance-1',
bootId: 'boot-1',
version: '0.1.32',
metadata: { name: ' hoopsheet ' },
},
events: [{
id: 'event-1',
sequence: 1,
Expand All @@ -127,6 +152,33 @@ describe('Factory cloud event v1 contract', () => {
}],
})
expect(parsed.events).toHaveLength(1)
expect(parsed.instance.metadata?.name).toBe('hoopsheet')
expect(parsed).not.toHaveProperty('workspaceId')
})

it('keeps instance metadata.name optional and validates its bounded display value', () => {
const instance = { id: 'instance-1', bootId: 'boot-1', version: '0.1.32' }
const event = {
id: 'event-1',
sequence: 1,
occurredAt: '2026-07-19T12:00:00.000Z',
type: 'instance.heartbeat',
}

expect(FactoryCloudEventBatchV1Schema.parse({
contract: 'factory.telemetry.v1',
instance,
events: [event],
}).instance).toEqual(instance)
expect(() => FactoryCloudEventBatchV1Schema.parse({
contract: 'factory.telemetry.v1',
instance: { ...instance, metadata: { name: ' ' } },
events: [event],
})).toThrow()
expect(() => FactoryCloudEventBatchV1Schema.parse({
contract: 'factory.telemetry.v1',
instance: { ...instance, metadata: { name: 'x'.repeat(257) } },
events: [event],
})).toThrow()
})
})
16 changes: 16 additions & 0 deletions src/observability/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,21 @@ export const FACTORY_CLOUD_RELEASE_REASONS_V1 = [
'reconciled_missing',
'delivery_failed',
'dispatch_failed',
'source_state_changed',
'other',
] as const

export type FactoryCloudReleaseReasonV1 = (typeof FACTORY_CLOUD_RELEASE_REASONS_V1)[number]

export const FACTORY_CLOUD_CANCELLATION_REASONS_V1 = [
'source_state_changed',
'agent_spawn_failed',
'agent_delivery_failed',
'dispatch_failed',
] as const

export type FactoryCloudCancellationReasonV1 = (typeof FACTORY_CLOUD_CANCELLATION_REASONS_V1)[number]

export const FACTORY_CLOUD_EVENT_TYPES = [
'instance.started',
'instance.heartbeat',
Expand Down Expand Up @@ -98,6 +108,7 @@ export const FactoryCloudEventAttributesV1Schema = z.object({
nodeId: opaqueString.optional(),
previousPhase: categoryString.optional(),
releaseReason: z.enum(FACTORY_CLOUD_RELEASE_REASONS_V1).optional(),
cancellationReason: z.enum(FACTORY_CLOUD_CANCELLATION_REASONS_V1).optional(),
dryRun: z.boolean().optional(),
count: boundedCount.optional(),
activeRuns: boundedCount.optional(),
Expand All @@ -118,6 +129,7 @@ export const FactoryCloudInstanceV1Schema = z.object({
bootId: opaqueString,
version: z.string().trim().min(1).max(64),
metadata: z.object({
name: opaqueString.optional(),
backend: categoryString.optional(),
mode: categoryString.optional(),
platform: categoryString.optional(),
Expand Down Expand Up @@ -234,6 +246,7 @@ export function factoryCloudReleaseReasonV1(value: string | undefined): FactoryC
case 'waiting-for-human':
return 'waiting_for_human'
case 'task_exit':
case 'worker_exited':
case 'exited':
return 'exited'
case 'offline':
Expand All @@ -252,6 +265,9 @@ export function factoryCloudReleaseReasonV1(value: string | undefined): FactoryC
return 'delivery_failed'
case 'dispatch failed':
return 'dispatch_failed'
case 'live dispatch state changed':
case 'source state changed':
return 'source_state_changed'
default:
return 'other'
}
Expand Down
28 changes: 27 additions & 1 deletion src/observability/instance-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,33 @@ import { join } from 'node:path'

import { describe, expect, it } from 'vitest'

import { loadOrCreateFactoryInstanceId } from './instance-identity'
import { loadOrCreateFactoryInstanceId, resolveFactoryInstanceName } from './instance-identity'

describe('resolveFactoryInstanceName', () => {
it('prefers an explicit reporting name over the sole configured repository', () => {
expect(resolveFactoryInstanceName({
reporting: { instanceName: ' Production Factory ' },
repos: { names: ['hoopsheet'] },
})).toBe('Production Factory')
})

it('uses the trimmed sole repository name for HoopSheet', () => {
expect(resolveFactoryInstanceName({
reporting: {},
repos: { names: [' hoopsheet '] },
})).toBe('hoopsheet')
})

it.each([
{ names: undefined, description: 'no configured repositories' },
{ names: [], description: 'an empty repository list' },
{ names: ['pear', 'hoopsheet'], description: 'multiple repositories' },
{ names: [' '], description: 'an empty trimmed repository name' },
{ names: ['x'.repeat(257)], description: 'an overlong repository name' },
])('omits the name for $description instead of deriving one from the hostname', ({ names }) => {
expect(resolveFactoryInstanceName({ reporting: {}, repos: { names } })).toBeUndefined()
})
})

describe('loadOrCreateFactoryInstanceId', () => {
it('persists one opaque identity across starts and concurrent creators', async () => {
Expand Down
24 changes: 24 additions & 0 deletions src/observability/instance-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ import { link, mkdir, open, readFile, unlink } from 'node:fs/promises'
import { dirname } from 'node:path'

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
const MAX_FACTORY_INSTANCE_NAME_LENGTH = 256

export interface FactoryInstanceNameConfig {
reporting: { instanceName?: string }
repos: { names?: readonly string[] }
}

/**
* Resolve the operator-facing instance name without fingerprinting the host.
* An explicit reporting name wins; otherwise only an unambiguous, valid sole
* repository name is used.
*/
export function resolveFactoryInstanceName(config: FactoryInstanceNameConfig): string | undefined {
if (config.reporting.instanceName !== undefined) {
return normalizeFactoryInstanceName(config.reporting.instanceName)
}
if (config.repos.names?.length !== 1) return undefined
return normalizeFactoryInstanceName(config.repos.names[0])
}

/**
* Return one opaque identity for this Factory installation. The create-only
Expand Down Expand Up @@ -53,3 +72,8 @@ const isAlreadyExistsError = (error: unknown): boolean =>

const isMissingFileError = (error: unknown): boolean =>
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')

const normalizeFactoryInstanceName = (value: string): string | undefined => {
const name = value.trim()
return name.length >= 1 && name.length <= MAX_FACTORY_INSTANCE_NAME_LENGTH ? name : undefined
}
Loading