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
18 changes: 5 additions & 13 deletions src/capture/platform/darwin-watcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { EventEmitter } from 'node:events'
import { stat, unlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { stat } from 'node:fs/promises'
import path from 'node:path'

import type { RawEvent } from '../types'
Expand All @@ -12,6 +11,7 @@ import type {
} from './types'
import { COMMON_PRUNE_DIRS } from './prune-dirs'
import { streamLines } from './stream-lines'
import { createMarkerFiles } from './marker-files'

// FSEvents flag constants
const ItemRemoved = 0x00000200
Expand Down Expand Up @@ -186,20 +186,14 @@ export class DarwinWatcher extends EventEmitter implements PlatformWatcher {
private async *backfillFind(opts: BackfillOptions): AsyncIterable<RawEvent> {
const { since_ts, until_ts, roots, signal } = opts

const tmp = tmpdir()
const pid = process.pid
const timestamp = Date.now()
const startMarker = path.join(tmp, `wildcard-start-${pid}-${timestamp}`)
const endMarker = path.join(tmp, `wildcard-end-${pid}-${timestamp}`)
const markers = await createMarkerFiles('wildcard-find-')
const { start: startMarker, end: endMarker } = markers

try {
// Create marker files with precise timestamps
const sinceDate = new Date(since_ts - CLOCK_SKEW_BUFFER_MS).toISOString()
const untilDate = new Date(until_ts + CLOCK_SKEW_BUFFER_MS).toISOString()

await writeFile(startMarker, '')
await writeFile(endMarker, '')

await Bun.spawn(['touch', '-d', sinceDate, startMarker]).exited
await Bun.spawn(['touch', '-d', untilDate, endMarker]).exited

Expand Down Expand Up @@ -253,9 +247,7 @@ export class DarwinWatcher extends EventEmitter implements PlatformWatcher {
}
}
} finally {
// Always cleanup marker files
await unlink(startMarker).catch(() => {})
await unlink(endMarker).catch(() => {})
await markers.cleanup()
}
}

Expand Down
22 changes: 22 additions & 0 deletions src/capture/platform/marker-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import path from 'node:path'

import { describe, expect, it } from 'bun:test'

import { createMarkerFiles } from './marker-files'

describe('createMarkerFiles', () => {
it('creates private marker files inside a random temp directory and cleans them up', async () => {
const markers = await createMarkerFiles('wildcard-marker-test-')

expect(path.basename(markers.start)).toBe('start')
expect(path.basename(markers.end)).toBe('end')
expect(markers.start).not.toBe(markers.end)
expect(await Bun.file(markers.start).exists()).toBe(true)
expect(await Bun.file(markers.end).exists()).toBe(true)

await markers.cleanup()

expect(await Bun.file(markers.start).exists()).toBe(false)
expect(await Bun.file(markers.end).exists()).toBe(false)
})
})
30 changes: 30 additions & 0 deletions src/capture/platform/marker-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'

export type MarkerFiles = {
start: string
end: string
cleanup: () => Promise<void>
}

export async function createMarkerFiles(prefix: string): Promise<MarkerFiles> {
const dir = await mkdtemp(path.join(tmpdir(), prefix))
const start = path.join(dir, 'start')
const end = path.join(dir, 'end')

await writeFile(start, '', { flag: 'wx', mode: 0o600 })
await writeFile(end, '', { flag: 'wx', mode: 0o600 })

let cleaned = false

return {
start,
end,
async cleanup() {
if (cleaned) return
cleaned = true
await rm(dir, { recursive: true, force: true }).catch(() => {})
},
}
}
28 changes: 5 additions & 23 deletions src/capture/platform/polling-watcher.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { EventEmitter } from 'node:events'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { stat, unlink } from 'node:fs/promises'
import { stat } from 'node:fs/promises'
import type { RawEvent } from '../types'
import { POLLING_PRUNE_DIRS } from './prune-dirs'
import { streamLines } from './stream-lines'
import { createMarkerFiles } from './marker-files'
import type {
PlatformWatcher,
PlatformWatcherOptions,
Expand All @@ -24,15 +24,13 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher {
private roots: string[]
private ignoreFn?: (path: string) => boolean
private intervalMs: number
private pid: number

constructor(options: PlatformWatcherOptions) {
super()
const { roots, ignore_fn, poll_interval_ms } = options
this.roots = roots.map((root) => path.resolve(root))
this.ignoreFn = ignore_fn
this.intervalMs = poll_interval_ms ?? 300_000
this.pid = process.pid
}

async start(): Promise<void> {
Expand Down Expand Up @@ -85,13 +83,10 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher {
signal?: AbortSignal,
): AsyncIterable<RawEvent> {
const targetRoots = roots ?? this.roots
const startMarker = this.markerPath('start', sinceTs)
const endMarker = this.markerPath('end', untilTs)
const markers = await createMarkerFiles('wildcard-poll-')
const { start: startMarker, end: endMarker } = markers

try {
await Bun.write(startMarker, '')
await Bun.write(endMarker, '')

await this.touchWithTime(startMarker, sinceTs)
await this.touchWithTime(endMarker, untilTs)

Expand Down Expand Up @@ -123,8 +118,7 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher {
} satisfies WatcherError)
}
} finally {
await this.cleanupMarker(startMarker)
await this.cleanupMarker(endMarker)
await markers.cleanup()
}
}

Expand All @@ -145,10 +139,6 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher {
return args
}

private markerPath(type: 'start' | 'end', timestamp: number): string {
return path.join(tmpdir(), `wildcard-poll-${this.pid}-${type}-${timestamp}`)
}

private async touchWithTime(filePath: string, timestamp: number): Promise<void> {
const dateStr = new Date(timestamp).toISOString()

Expand All @@ -165,14 +155,6 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher {
}
}

private async cleanupMarker(filePath: string): Promise<void> {
try {
await unlink(filePath)
} catch {
// Ignore errors - file may already be gone
}
}

private async fileMtimeMs(filePath: string): Promise<number | null> {
try {
const st = await stat(filePath)
Expand Down
21 changes: 20 additions & 1 deletion src/commands/activity-report.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'bun:test'

import { canUseReportEnrichment, shouldAutoEnrichReport } from './report-enrichment'
import {
canUseReportEnrichment,
isReportEnrichDisabled,
shouldAutoEnrichReport,
} from './report-enrichment'

describe('report enrichment gating', () => {
it('uses enrichment only for local/all-device AI reports', () => {
Expand Down Expand Up @@ -43,5 +47,20 @@ describe('report enrichment gating', () => {
noEnrich: true,
}),
).toBe(false)

expect(
shouldAutoEnrichReport({
deviceScope: '*',
hasAi: true,
enrichmentEnabled: true,
enrich: false,
}),
).toBe(false)
})

it('treats both commander negation forms as enrichment opt-out', () => {
expect(isReportEnrichDisabled({ noEnrich: true })).toBe(true)
expect(isReportEnrichDisabled({ enrich: false })).toBe(true)
expect(isReportEnrichDisabled({ noEnrich: false, enrich: true })).toBe(false)
})
})
1 change: 1 addition & 0 deletions src/commands/activity-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export async function cmdReport(
hasAi,
enrichmentEnabled: Boolean(cfg.enrichment),
noEnrich: opts.noEnrich,
enrich: opts.enrich,
})

if (shouldAutoEnrich) {
Expand Down
7 changes: 6 additions & 1 deletion src/commands/report-enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ export function canUseReportEnrichment(deviceScope: string | undefined, hasAi: b
return shouldInjectEnrichment && hasAi
}

export function isReportEnrichDisabled(options: { noEnrich?: boolean; enrich?: boolean }): boolean {
return options.noEnrich === true || options.enrich === false
}

export function shouldAutoEnrichReport(options: {
deviceScope: string | undefined
hasAi: boolean
enrichmentEnabled: boolean
noEnrich?: boolean
enrich?: boolean
}): boolean {
return (
canUseReportEnrichment(options.deviceScope, options.hasAi) &&
options.enrichmentEnabled &&
!options.noEnrich
!isReportEnrichDisabled(options)
)
}
33 changes: 32 additions & 1 deletion src/commands/setup.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test'

import { applyEnrichmentSetupChoice } from './setup'
import { applyEnrichmentSetupChoice, buildAiSetupConfig } from './setup'

describe('applyEnrichmentSetupChoice', () => {
it('removes stale enrichment config when disabled', () => {
Expand Down Expand Up @@ -33,3 +33,34 @@ describe('applyEnrichmentSetupChoice', () => {
})
})
})

describe('buildAiSetupConfig', () => {
it('does not retain provider credentials when switching to a different provider', () => {
const cfg = buildAiSetupConfig({
provider: 'gemini',
model: 'gemini-3.1-flash-lite-preview',
})

expect(cfg).toEqual({
enabled: true,
provider: 'gemini',
model: 'gemini-3.1-flash-lite-preview',
})
})

it('keeps ollama base URL separate from remote-provider api keys', () => {
const cfg = buildAiSetupConfig({
provider: 'ollama',
model: 'qwen3.5:latest',
baseUrl: 'http://localhost:11434',
apiKey: 'should-not-be-used',
})

expect(cfg).toEqual({
enabled: true,
provider: 'ollama',
model: 'qwen3.5:latest',
base_url: 'http://localhost:11434',
})
})
})
30 changes: 24 additions & 6 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { expandHome } from '../config/path-utils'
import { ensureDir } from '../utils/process'

import { exitIfCancelled } from './cli-helpers'
import type { AiProviderKind } from '../ai/index'

function resolveWatchRootsDefault(home: string, cwd: string): string {
const candidates = [
Expand Down Expand Up @@ -52,6 +53,25 @@ export function applyEnrichmentSetupChoice(
existingConfig.enrichment = enrichCfg
}

export function buildAiSetupConfig(options: {
provider: AiProviderKind
model: string
baseUrl?: string
apiKey?: string
}): WildcardConfig['ai'] {
const base = {
enabled: true as const,
provider: options.provider,
model: options.model,
}

if (options.provider === 'ollama') {
return options.baseUrl ? { ...base, base_url: options.baseUrl } : base
}

return options.apiKey ? { ...base, api_key: options.apiKey } : base
}

export async function cmdSetup(): Promise<void> {
intro('Wildcard setup')

Expand Down Expand Up @@ -217,14 +237,12 @@ export async function cmdSetup(): Promise<void> {
}

if (enableAi) {
const aiCfg: Record<string, unknown> = {
...(existingAi ?? {}),
enabled: true,
const aiCfg = buildAiSetupConfig({
provider: aiProvider,
model: aiModel,
}
if (aiBaseUrl) aiCfg.base_url = aiBaseUrl
if (aiNeedsApiKey && aiApiKey) aiCfg.api_key = aiApiKey
baseUrl: aiBaseUrl,
apiKey: aiNeedsApiKey ? aiApiKey : undefined,
})
cfg.ai = aiCfg
} else if (existingAi) {
cfg.ai = {
Expand Down
4 changes: 1 addition & 3 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,7 @@ function countPendingRows(db: TimelineDb, table: string, cursorKey: string): num
try {
const rawCursor = db.getState(cursorKey)
const cursor =
rawCursor !== null && rawCursor !== undefined
? Number.parseInt(rawCursor, 10)
: Number.NaN
rawCursor !== null && rawCursor !== undefined ? Number.parseInt(rawCursor, 10) : Number.NaN
const hasCursor = Number.isFinite(cursor) && cursor >= 0
const sql = hasCursor
? `SELECT COUNT(*) as cnt FROM ${table} WHERE rowid > ?`
Expand Down
1 change: 1 addition & 0 deletions src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type StatusOptions = {

export type ReportOptions = PeriodFlags & {
ai?: boolean
enrich?: boolean
noAi?: boolean
noEnrich?: boolean
forceRefresh?: boolean
Expand Down
4 changes: 2 additions & 2 deletions src/enrichment/git-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,11 @@ export async function gather_git_context(
{ timeout_ms: timeout },
),
run_git(repoRoot, ['status', '--short'], { timeout_ms: timeout }),
run_git(repoRoot, ['diff', '--cached'], {
run_git(repoRoot, ['diff', '--cached', '-U0'], {
timeout_ms: timeout,
max_bytes: maxDiff,
}),
run_git(repoRoot, ['diff'], {
run_git(repoRoot, ['diff', '-U0'], {
timeout_ms: timeout,
max_bytes: maxDiff,
}),
Expand Down
Loading
Loading