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
184 changes: 181 additions & 3 deletions src/main/lib/agentTap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const {
ALLOWED_EVENTS,
ALLOWED_FIELD_NAMES,
REASONS,
STARTUP_STEPS,
PRODUCT_EVENTS,
PRODUCT_FIELDS,
_resetProductEventBudgetForTest
Expand All @@ -58,7 +59,12 @@ const AGENT_EVENTS = [
'node_fetch_failed',
'health_check_failed',
'agent_exited',
'agent_error'
'agent_error',
'startup_step',
'startup_stalled',
'startup_failed',
'document_host_degraded',
'agent_orphans_stopped'
]

describe('agentTap', () => {
Expand Down Expand Up @@ -114,7 +120,32 @@ describe('agentTap', () => {

it('exposes exactly the agent field allowlist', () => {
expect([...ALLOWED_FIELD_NAMES].sort()).toEqual(
['agent_version', 'code', 'duration_ms', 'node_version', 'reason'].sort()
[
'agent_version',
'code',
'duration_ms',
'node_version',
'reason',
'step',
'outcome',
'attempt',
'count',
'uptime_ms',
'startup_ms',
'ready',
'crash',
'missing',
'python_arch',
'python_version',
'crdt',
'cli',
'comfyui',
'comfyui_found_by',
'comfyui_match',
'shell',
'sandbox',
'assets'
].sort()
)
})

Expand Down Expand Up @@ -189,7 +220,11 @@ describe('agentTap', () => {
'checksum_mismatch',
'permission_denied',
'disabled',
'unknown'
'unknown',
'data_dir_locked',
'address_in_use',
'not_set_up',
'config_invalid'
].sort()
)
})
Expand Down Expand Up @@ -411,6 +446,17 @@ describe('agentTap', () => {
})
})

describe('startup step cap', () => {
it('holds four full launches an hour', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const tap = createAgentTap(baseOpts)
const line = '[agent-event] startup_step step=listen outcome=ok duration_ms=1\n'
tap.ingest(line.repeat(241), 'stdout')
expect(captured).toHaveLength(240)
})
})

describe('rate window', () => {
it('keeps the cap until a full hour has passed', () => {
vi.useFakeTimers()
Expand Down Expand Up @@ -915,3 +961,135 @@ describe('agentTap consent gating', () => {
])
})
})

// The host's startup records, byte-identical to cloud's
// services/agent/packaging/testdata/startup_events.txt, and the step ids to
// its startup_step_ids.txt.
const STARTUP_FIXTURE = fs
.readFileSync(path.join(__dirname, 'fixtures', 'agent-startup-events.txt'), 'utf8')
.trimEnd()
.split('\n')
const STARTUP_STEP_IDS = fs
.readFileSync(path.join(__dirname, 'fixtures', 'agent-startup-step-ids.txt'), 'utf8')
.trim()
.split('\n')

describe('agentTap startup records', () => {
let captured: Array<{ event: string; ctx: Record<string, unknown> }>

beforeEach(() => {
captured = []
vi.spyOn(telemetry, 'emit').mockImplementation((event, ctx) => {
captured.push({ event, ctx: ctx as Record<string, unknown> })
})
})

afterEach(() => {
vi.restoreAllMocks()
})

// As ComfyUI prints a record it logs: ANSI level tag, CRLF on Windows.
function ingest(lines: string[]): void {
createAgentTap({ installationId: 'inst-1' }).ingest(
lines.map((line) => `\u001b[32m[INFO]\u001b[0m ${line}\r\n`).join(''),
'stderr'
)
}

function fields(line: string): Record<string, unknown> {
ingest([line])
expect(captured, line).toHaveLength(1)
const ctx = { ...captured[0]?.ctx }
for (const key of ['installation_id', 'variant', 'release', 'core_beta_flags']) delete ctx[key]
captured = []
return ctx
}

it('knows every step id the agent reports, plus init and other', () => {
expect([...STARTUP_STEPS].sort()).toEqual([...STARTUP_STEP_IDS, 'init', 'other'].sort())
})

it('forwards every fixture record with its typed fields', () => {
ingest(STARTUP_FIXTURE)
expect(captured.map((c) => c.event)).toEqual(
STARTUP_FIXTURE.map((line) => `comfy.desktop.comfyui.agent.${line.split(' ')[1]}`)
)
expect(captured[1]?.ctx).toMatchObject({
step: 'cli_provision',
outcome: 'degraded',
duration_ms: 30500,
reason: 'network_error'
})
expect(captured[4]?.ctx).toMatchObject({ step: 'object_info', reason: 'timeout' })
expect(captured[6]?.ctx).toMatchObject({ duration_ms: 41000, node_version: 'v22.11.0' })
})

it('types the host lifecycle fields', () => {
expect(
fields(
'[agent-event] agent_starting agent_version=0.1.0 attempt=2 python_arch=amd64 python_version=3.12.4'
)
).toEqual({
agent_version: '0.1.0',
attempt: 2,
python_arch: 'amd64',
python_version: '3.12.4'
})
expect(
fields(
'[agent-event] agent_started duration_ms=4100 agent_version=0.1.0 startup_ms=3900 crdt=on cli=provisioned comfyui=found comfyui_found_by=explicit comfyui_match=mismatch shell=off sandbox=appcontainer assets=enabled'
)
).toEqual({
duration_ms: 4100,
agent_version: '0.1.0',
startup_ms: 3900,
crdt: 'on',
cli: 'provisioned',
comfyui: 'found',
comfyui_found_by: 'explicit',
comfyui_match: 'mismatch',
shell: 'off',
sandbox: 'appcontainer',
assets: 'enabled'
})
expect(
fields('[agent-event] agent_exited code=2 uptime_ms=812 ready=false crash=panic')
).toEqual({
code: 2,
uptime_ms: 812,
ready: false,
crash: 'panic'
})
expect(fields('[agent-event] agent_orphans_stopped count=2')).toEqual({ count: 2 })
expect(fields('[agent-event] package_missing missing=comfy_cli_and_node')).toEqual({
missing: 'comfy_cli_and_node'
})
expect(fields('[agent-event] startup_failed step=init reason=config_invalid')).toEqual({
step: 'init',
reason: 'config_invalid'
})
})

it('reports a state value it does not know as unknown, keeping the event', () => {
expect(fields('[agent-event] startup_step step=new_step outcome=great duration_ms=5')).toEqual({
step: 'unknown',
outcome: 'unknown',
duration_ms: 5
})
})

it('drops an event whose count is negative or whose version is not one', () => {
ingest([
'[agent-event] agent_exited uptime_ms=-1',
'[agent-event] agent_orphans_stopped count=-3',
'[agent-event] agent_starting python_version=three'
])
expect(captured).toEqual([])
})

it('never lets a line set the platform or arch Desktop attaches itself', () => {
expect(fields('[agent-event] agent_starting attempt=1 platform=x arch=y')).toEqual({
attempt: 1
})
})
})
101 changes: 95 additions & 6 deletions src/main/lib/agentTap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ export const ALLOWED_EVENTS: ReadonlySet<string> = new Set([
'node_fetch_failed',
'health_check_failed',
'agent_exited',
'agent_error'
'agent_error',
// The agent's startup, built by its v1 host from the agent's own log
// (cloud services/agent/packaging/comfy_agent/host.py AgentRecords).
'startup_step',
'startup_stalled',
'startup_failed',
'document_host_degraded',
'agent_orphans_stopped'
])

// Absent from ALLOWED_EVENTS, so no line can forge it.
Expand All @@ -41,7 +48,34 @@ export const REASONS: ReadonlySet<string> = new Set([
'checksum_mismatch',
'permission_denied',
'disabled',
'unknown'
'unknown',
'data_dir_locked',
'address_in_use',
'not_set_up',
'config_invalid'
])

// The agent's startup step ids: byte-identical to cloud's
// services/agent/packaging/testdata/startup_step_ids.txt, plus "init" (a
// failure before the first step) and "other" (a step this list does not know).
export const STARTUP_STEPS: ReadonlySet<string> = new Set([
'init',
'open_data_dir',
'cli_provision',
'find_comfyui',
'code_search_canary',
'find_node',
'sandbox_config',
'egress_proxy',
'sandbox_setup',
'sandbox_grants',
'object_info',
'knowledge',
'cli_skills',
'detect_environment',
'document_host',
'listen',
'other'
])

// A Set, not an object literal, so untrusted keys like `constructor` can't hit the prototype.
Expand All @@ -50,7 +84,56 @@ export const ALLOWED_FIELD_NAMES: ReadonlySet<string> = new Set([
'duration_ms',
'agent_version',
'node_version',
'reason'
'reason',
// Typed in LIFECYCLE_ENUMS / LIFECYCLE_COUNTS / fieldValue.
'step',
'outcome',
'attempt',
'count',
'uptime_ms',
'startup_ms',
'ready',
'crash',
'missing',
'python_arch',
'python_version',
'crdt',
'cli',
'comfyui',
'comfyui_found_by',
'comfyui_match',
'shell',
'sandbox',
'assets'
])

// Closed sets for the lifecycle fields that name a state. A value outside its
// set still reports the event, as "unknown", like `reason`.
const LIFECYCLE_ENUMS: ReadonlyMap<string, ReadonlySet<string>> = new Map(
Object.entries({
step: STARTUP_STEPS,
outcome: ['ok', 'degraded', 'failed'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

outcome, comfyui and shell lack an 'other' value, unlike the other host-bucketed sets here (python_arch/crdt/cli/comfyui_found_by all include it). The host normalizes unknown values to other, so a host-sent other on these three lands as unknown — two spellings of "new value". Minor consistency fix: add 'other' to these three sets.

crash: ['panic', 'fatal_error', 'none'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No exact-set test covers LIFECYCLE_ENUMS, and crash=none is never asserted. If 'none' were dropped from this set, every normal agent_exited (host sends crash=none) would silently report crash: unknown with every test still green — the most realistic regression risk here. Add an exact-set assertion (specifying the values independently as producer values), plus negatives for attempt/startup_ms and a ready=true case.

missing: ['comfy_cli', 'node', 'comfy_cli_and_node'],
python_arch: ['amd64', 'arm64', 'other'],
crdt: ['on', 'disabled', 'failed', 'other'],
cli: ['provisioned', 'override', 'unavailable', 'other'],
comfyui: ['found', 'not_found'],
comfyui_found_by: ['explicit', 'server', 'known_location', 'none', 'other'],
comfyui_match: ['match', 'mismatch', 'unknown', 'other'],
shell: ['on', 'off'],
sandbox: ['none', 'seatbelt', 'appcontainer', 'other'],
assets: ['enabled', 'disabled', 'unknown', 'other']
}).map(([name, values]): [string, ReadonlySet<string>] => [name, new Set(values)])
)

// Lifecycle fields that are non-negative counts (unlike `code`, an exit code).
const LIFECYCLE_COUNTS: ReadonlySet<string> = new Set([
'duration_ms',
'attempt',
'count',
'uptime_ms',
'startup_ms'
])

const VERSION = /^v?\d{1,6}(?:\.\d{1,6}){1,3}(?:[-+.]?[0-9A-Za-z][0-9A-Za-z.+-]{0,39})?$/
Expand Down Expand Up @@ -175,11 +258,14 @@ type AgentTapOptions = {

function fieldValue(key: string, rawValue: string): TelemetryValue | undefined {
if (key === 'reason') return REASONS.has(rawValue) ? rawValue : 'unknown'
if (key === 'agent_version' || key === 'node_version') {
const values = LIFECYCLE_ENUMS.get(key)
if (values) return values.has(rawValue) ? rawValue : 'unknown'
if (key === 'ready') return bool(rawValue)
if (key === 'agent_version' || key === 'node_version' || key === 'python_version') {
return VERSION.test(rawValue) ? rawValue : undefined
}
const value = /^-?\d+$/.test(rawValue) ? Number(rawValue) : NaN
if (!Number.isSafeInteger(value) || (key === 'duration_ms' && value < 0)) return undefined
if (!Number.isSafeInteger(value) || (LIFECYCLE_COUNTS.has(key) && value < 0)) return undefined
return value
}

Expand Down Expand Up @@ -261,6 +347,9 @@ export function parseAgentEventLine(line: string): AgentEvent | null {

// Per-event budget on top of telemetry's own per-minute limit.
const PER_EVENT_HOURLY_CAP = 60
// One start writes a startup_step per step (15) on each of up to 4 attempts,
// so the default cap would hold one launch an hour.
const LIFECYCLE_HOURLY_CAPS: Readonly<Record<string, number>> = { startup_step: 240 }
const RATE_WINDOW_MS = 60 * 60_000

export function createAgentTap(opts: AgentTapOptions): {
Expand Down Expand Up @@ -313,7 +402,7 @@ export function createAgentTap(opts: AgentTapOptions): {
const { event, fields } =
parsed === UNKNOWN_EVENT ? { event: UNKNOWN_EVENTS_DROPPED, fields: { count: 1 } } : parsed
const bucket = rateBucket(event)
if (bucket.count >= PER_EVENT_HOURLY_CAP) return
if (bucket.count >= (LIFECYCLE_HOURLY_CAPS[event] ?? PER_EVENT_HOURLY_CAP)) return
bucket.count++
try {
// Base context merged last so parsed fields can never override it.
Expand Down
10 changes: 10 additions & 0 deletions src/main/lib/fixtures/agent-startup-events.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[agent-event] startup_step step=find_comfyui outcome=ok duration_ms=12
[agent-event] startup_step step=cli_provision outcome=degraded duration_ms=30500 reason=network_error
[agent-event] startup_step step=object_info outcome=failed duration_ms=120000 reason=timeout
[agent-event] startup_stalled step=sandbox_setup duration_ms=95000
[agent-event] startup_failed step=object_info reason=timeout
[agent-event] node_fetch_started
[agent-event] node_fetched duration_ms=41000 node_version=v22.11.0
[agent-event] node_fetch_failed duration_ms=360000 reason=timeout
[agent-event] node_found node_version=v20.18.1
[agent-event] document_host_degraded reason=crashed
15 changes: 15 additions & 0 deletions src/main/lib/fixtures/agent-startup-step-ids.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
open_data_dir
cli_provision
find_comfyui
code_search_canary
find_node
sandbox_config
egress_proxy
sandbox_setup
sandbox_grants
object_info
knowledge
cli_skills
detect_environment
document_host
listen
Loading
Loading