refactor: observability を logging に再編成し設定構造を体系化 (#466)

* takt: refactor-logging-config

* fix: resolve merge conflicts

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nrs 2026-03-04 20:27:42 +09:00 committed by GitHub
parent 289a0fb912
commit 69dd871404
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1089 additions and 226 deletions

View File

@ -601,17 +601,24 @@ Implemented in `src/core/runtime/runtime-environment.ts`.
## Debugging ## Debugging
**Debug logging:** Set `debug_enabled: true` in `~/.takt/config.yaml` or create a `.takt/debug.yaml` file: **Debug logging:** Set `logging.debug: true` in `~/.takt/config.yaml`:
```yaml ```yaml
enabled: true logging:
debug: true
``` ```
Debug logs are written to `.takt/logs/debug.log` (ndjson format). Log levels: `debug`, `info`, `warn`, `error`. Debug logs are written to `.takt/runs/debug-{timestamp}/logs/` in NDJSON format. Log levels: `debug`, `info`, `warn`, `error`.
**Verbose mode:** Create `.takt/verbose` file (empty file) to enable verbose console output. This automatically enables debug logging and sets log level to `debug`. **Verbose mode:** Set `verbose: true` in `~/.takt/config.yaml` or `TAKT_VERBOSE=true` to enable verbose console output. This enables `logging.debug`, `logging.trace`, and sets `logging.level` to `debug`.
**Session logs:** All piece executions are logged to `.takt/logs/{sessionId}.jsonl`. Use `tail -f .takt/logs/{sessionId}.jsonl` to monitor in real-time. **Session logs:** All piece executions are logged to `.takt/logs/{sessionId}.jsonl`. Use `tail -f .takt/logs/{sessionId}.jsonl` to monitor in real-time.
**Environment variables:**
- `TAKT_LOGGING_LEVEL=info`
- `TAKT_LOGGING_PROVIDER_EVENTS=true`
- `TAKT_VERBOSE=true`
**Testing with mocks:** Use `--provider mock` to test pieces without calling real AI APIs. Mock responses are deterministic and configurable via test fixtures. **Testing with mocks:** Use `--provider mock` to test pieces without calling real AI APIs. Mock responses are deterministic and configurable via test fixtures.
## Testing Notes ## Testing Notes

View File

@ -20,8 +20,12 @@ language: en # UI language: en | ja
# piece_abort: true # piece_abort: true
# run_complete: true # run_complete: true
# run_abort: true # run_abort: true
# observability: # verbose: false # Shortcut: enable trace/debug and set logging.level=debug
# provider_events: false # Persist provider stream events # logging:
# level: info # Log level for console and file output
# trace: true # Generate human-readable execution trace report (trace.md)
# debug: false # Enable debug.log + prompts.jsonl
# provider_events: false # Persist provider stream events
# Credentials (environment variables take priority) # Credentials (environment variables take priority)
# anthropic_api_key: "sk-ant-..." # Claude API key # anthropic_api_key: "sk-ant-..." # Claude API key

View File

@ -20,7 +20,11 @@ language: ja # 表示言語: ja | en
# piece_abort: true # piece_abort: true
# run_complete: true # run_complete: true
# run_abort: true # run_abort: true
# observability: # verbose: false # ショートカット: trace/debug有効化 + logging.level=debug
# logging:
# level: info # ログレベル: debug | info | warn | error
# trace: true # trace.md 実行レポート生成
# debug: false # debug.log + prompts.jsonl を有効化
# provider_events: false # providerイベントログを記録 # provider_events: false # providerイベントログを記録
# 認証情報(環境変数優先) # 認証情報(環境変数優先)

View File

@ -12,7 +12,8 @@
# ~/.takt/config.yaml # ~/.takt/config.yaml
language: en # UI 言語: 'en' または 'ja' language: en # UI 言語: 'en' または 'ja'
default_piece: default # 新規プロジェクトのデフォルト piece default_piece: default # 新規プロジェクトのデフォルト piece
log_level: info # ログレベル: debug, info, warn, error logging:
level: info # ログレベル: debug, info, warn, error
provider: claude # デフォルト provider: claude, codex, opencode, cursor, または copilot provider: claude # デフォルト provider: claude, codex, opencode, cursor, または copilot
model: sonnet # デフォルトモデル省略可、provider にそのまま渡される) model: sonnet # デフォルトモデル省略可、provider にそのまま渡される)
branch_name_strategy: romaji # ブランチ名生成方式: 'romaji'(高速)または 'ai'(低速) branch_name_strategy: romaji # ブランチ名生成方式: 'romaji'(高速)または 'ai'(低速)
@ -92,7 +93,7 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
|-----------|------|---------|------| |-----------|------|---------|------|
| `language` | `"en"` \| `"ja"` | `"en"` | UI 言語 | | `language` | `"en"` \| `"ja"` | `"en"` | UI 言語 |
| `default_piece` | string | `"default"` | 新規プロジェクトのデフォルト piece | | `default_piece` | string | `"default"` | 新規プロジェクトのデフォルト piece |
| `log_level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | ログレベル | | `logging.level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | ログレベル |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"cursor"` \| `"copilot"` | `"claude"` | デフォルト AI provider | | `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"cursor"` \| `"copilot"` | `"claude"` | デフォルト AI provider |
| `model` | string | - | デフォルトモデル名provider にそのまま渡される) | | `model` | string | - | デフォルトモデル名provider にそのまま渡される) |
| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | ブランチ名生成方式 | | `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | ブランチ名生成方式 |
@ -434,22 +435,27 @@ pipeline:
### デバッグログ ### デバッグログ
`~/.takt/config.yaml``debug_enabled: true` を設定するか、`.takt/debug.yaml` ファイルを作成してデバッグログを有効化できます。 `~/.takt/config.yaml``logging.debug: true` を設定してデバッグログを有効化できます。
```yaml ```yaml
# .takt/debug.yaml logging:
enabled: true debug: true
``` ```
デバッグログは `.takt/logs/debug.log` に NDJSON 形式で出力されます。 デバッグログは `.takt/runs/debug-{timestamp}/logs/debug.log` に NDJSON 形式で出力されます。
### 詳細モード ### 詳細モード
空の `.takt/verbose` ファイルを作成すると、詳細なコンソール出力が有効になります。これにより、デバッグログも自動的に有効化されます。 `verbose: true` を設定すると、詳細なコンソール出力が有効になります。これにより、デバッグログ・トレースも有効化され、ログレベルが `debug` になります。
または、設定ファイルで `verbose: true` を設定することもできます。 または、環境変数で `TAKT_VERBOSE=true` を指定して有効化できます。
```yaml ```yaml
# ~/.takt/config.yaml または .takt/config.yaml # ~/.takt/config.yaml または .takt/config.yaml
verbose: true verbose: true
``` ```
```bash
# env
TAKT_VERBOSE=true
```

View File

@ -12,7 +12,8 @@ Configure TAKT defaults in `~/.takt/config.yaml`. This file is created automatic
# ~/.takt/config.yaml # ~/.takt/config.yaml
language: en # UI language: 'en' or 'ja' language: en # UI language: 'en' or 'ja'
default_piece: default # Default piece for new projects default_piece: default # Default piece for new projects
log_level: info # Log level: debug, info, warn, error logging:
level: info # Log level: debug, info, warn, error
provider: claude # Default provider: claude, codex, opencode, cursor, or copilot provider: claude # Default provider: claude, codex, opencode, cursor, or copilot
model: sonnet # Default model (optional, passed to provider as-is) model: sonnet # Default model (optional, passed to provider as-is)
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow) branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
@ -92,7 +93,7 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10,
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `language` | `"en"` \| `"ja"` | `"en"` | UI language | | `language` | `"en"` \| `"ja"` | `"en"` | UI language |
| `default_piece` | string | `"default"` | Default piece for new projects | | `default_piece` | string | `"default"` | Default piece for new projects |
| `log_level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | Log level | | `logging.level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | Log level |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"cursor"` \| `"copilot"` | `"claude"` | Default AI provider | | `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"cursor"` \| `"copilot"` | `"claude"` | Default AI provider |
| `model` | string | - | Default model name (passed to provider as-is) | | `model` | string | - | Default model name (passed to provider as-is) |
| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | Branch name generation strategy | | `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | Branch name generation strategy |
@ -434,22 +435,28 @@ pipeline:
### Debug Logging ### Debug Logging
Enable debug logging by setting `debug_enabled: true` in `~/.takt/config.yaml` or by creating a `.takt/debug.yaml` file: Enable debug logging by setting `logging.debug: true` in `~/.takt/config.yaml`:
```yaml ```yaml
# .takt/debug.yaml logging:
enabled: true debug: true
``` ```
Debug logs are written to `.takt/logs/debug.log` in NDJSON format. Debug logs are written to `.takt/runs/debug-{timestamp}/logs/debug.log` in NDJSON format.
### Verbose Mode ### Verbose Mode
Create an empty `.takt/verbose` file to enable verbose console output. This automatically enables debug logging. Set `verbose: true` in your config:
Alternatively, set `verbose: true` in your config:
```yaml ```yaml
# ~/.takt/config.yaml or .takt/config.yaml # ~/.takt/config.yaml or .takt/config.yaml
verbose: true verbose: true
``` ```
You can also force verbose output via environment variable:
```yaml
TAKT_VERBOSE=true
```
This also enables `logging.debug`, `logging.trace`, and sets `logging.level` to `debug`.

View File

@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
applyGlobalConfigEnvOverrides, applyGlobalConfigEnvOverrides,
applyProjectConfigEnvOverrides, applyProjectConfigEnvOverrides,
@ -52,7 +52,6 @@ describe('config env overrides', () => {
}); });
it('should apply project env overrides from generated env names', () => { it('should apply project env overrides from generated env names', () => {
process.env.TAKT_LOG_LEVEL = 'debug';
process.env.TAKT_MODEL = 'gpt-5'; process.env.TAKT_MODEL = 'gpt-5';
process.env.TAKT_VERBOSE = 'true'; process.env.TAKT_VERBOSE = 'true';
process.env.TAKT_CONCURRENCY = '3'; process.env.TAKT_CONCURRENCY = '3';
@ -61,7 +60,6 @@ describe('config env overrides', () => {
const raw: Record<string, unknown> = {}; const raw: Record<string, unknown> = {};
applyProjectConfigEnvOverrides(raw); applyProjectConfigEnvOverrides(raw);
expect(raw.log_level).toBe('debug');
expect(raw.model).toBe('gpt-5'); expect(raw.model).toBe('gpt-5');
expect(raw.verbose).toBe(true); expect(raw.verbose).toBe(true);
expect(raw.concurrency).toBe(3); expect(raw.concurrency).toBe(3);
@ -85,6 +83,140 @@ describe('config env overrides', () => {
}); });
}); });
it('should apply logging env overrides for global config', () => {
process.env.TAKT_LOGGING_LEVEL = 'debug';
process.env.TAKT_LOGGING_TRACE = 'true';
process.env.TAKT_LOGGING_DEBUG = 'true';
process.env.TAKT_LOGGING_PROVIDER_EVENTS = 'true';
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.logging).toEqual({
level: 'debug',
trace: true,
debug: true,
provider_events: true,
});
});
it('should let logging leaf env vars override TAKT_LOGGING JSON', () => {
process.env.TAKT_LOGGING = '{"level":"info","trace":true,"debug":false}';
process.env.TAKT_LOGGING_LEVEL = 'warn';
process.env.TAKT_LOGGING_DEBUG = 'true';
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.logging).toEqual({
level: 'warn',
trace: true,
debug: true,
});
});
it('should map TAKT_LOGGING_LEVEL as global logging.level override', () => {
process.env.TAKT_LOGGING_LEVEL = 'warn';
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.logging).toEqual({
level: 'warn',
});
});
it('should apply logging JSON override for global config', () => {
process.env.TAKT_LOGGING = '{"level":"warn","debug":true}';
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.logging).toEqual({
level: 'warn',
debug: true,
});
});
it('should map TAKT_LOG_LEVEL to logging.level with deprecation warning', () => {
process.env.TAKT_LOG_LEVEL = 'warn';
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.logging).toEqual({
level: 'warn',
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('TAKT_LOG_LEVEL'),
);
} finally {
warnSpy.mockRestore();
}
});
it('should map TAKT_OBSERVABILITY_PROVIDER_EVENTS to logging.provider_events with deprecation warning', () => {
process.env.TAKT_OBSERVABILITY_PROVIDER_EVENTS = 'true';
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.logging).toEqual({
provider_events: true,
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('TAKT_OBSERVABILITY_PROVIDER_EVENTS'),
);
} finally {
warnSpy.mockRestore();
}
});
it('should prefer TAKT_LOGGING_* over legacy logging env vars', () => {
process.env.TAKT_LOGGING_LEVEL = 'info';
process.env.TAKT_LOG_LEVEL = 'debug';
process.env.TAKT_LOGGING_PROVIDER_EVENTS = 'false';
process.env.TAKT_OBSERVABILITY_PROVIDER_EVENTS = 'true';
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.logging).toEqual({
level: 'info',
provider_events: false,
});
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
it('should prefer TAKT_LOGGING JSON over legacy logging env vars', () => {
process.env.TAKT_LOGGING = '{"level":"error","provider_events":false}';
process.env.TAKT_LOG_LEVEL = 'debug';
process.env.TAKT_OBSERVABILITY_PROVIDER_EVENTS = 'true';
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.logging).toEqual({
level: 'error',
provider_events: false,
});
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
it('should apply cursor API key override for global config', () => { it('should apply cursor API key override for global config', () => {
process.env.TAKT_CURSOR_API_KEY = 'cursor-key-from-env'; process.env.TAKT_CURSOR_API_KEY = 'cursor-key-from-env';
process.env.TAKT_GEMINI_API_KEY = 'gemini-key-from-env'; process.env.TAKT_GEMINI_API_KEY = 'gemini-key-from-env';

View File

@ -12,8 +12,18 @@ describe('config module file-size boundary', () => {
expect(lineCount).toBeLessThanOrEqual(300); expect(lineCount).toBeLessThanOrEqual(300);
}); });
it('keeps globalConfig.ts as thin facade under 120 lines', () => {
const lineCount = getLineCount('../infra/config/global/globalConfig.ts');
expect(lineCount).toBeLessThanOrEqual(120);
});
it('keeps projectConfig.ts under 300 lines', () => { it('keeps projectConfig.ts under 300 lines', () => {
const lineCount = getLineCount('../infra/config/project/projectConfig.ts'); const lineCount = getLineCount('../infra/config/project/projectConfig.ts');
expect(lineCount).toBeLessThanOrEqual(300); expect(lineCount).toBeLessThanOrEqual(300);
}); });
it('keeps pieceExecution.ts under 300 lines', () => {
const lineCount = getLineCount('../features/tasks/execute/pieceExecution.ts');
expect(lineCount).toBeLessThanOrEqual(300);
});
}); });

View File

@ -561,14 +561,20 @@ describe('isVerboseMode', () => {
let testDir: string; let testDir: string;
let originalTaktConfigDir: string | undefined; let originalTaktConfigDir: string | undefined;
let originalTaktVerbose: string | undefined; let originalTaktVerbose: string | undefined;
let originalTaktLoggingDebug: string | undefined;
let originalTaktLoggingTrace: string | undefined;
beforeEach(() => { beforeEach(() => {
testDir = join(tmpdir(), `takt-test-${randomUUID()}`); testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true }); mkdirSync(testDir, { recursive: true });
originalTaktConfigDir = process.env.TAKT_CONFIG_DIR; originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
originalTaktVerbose = process.env.TAKT_VERBOSE; originalTaktVerbose = process.env.TAKT_VERBOSE;
originalTaktLoggingDebug = process.env.TAKT_LOGGING_DEBUG;
originalTaktLoggingTrace = process.env.TAKT_LOGGING_TRACE;
process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt'); process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt');
delete process.env.TAKT_VERBOSE; delete process.env.TAKT_VERBOSE;
delete process.env.TAKT_LOGGING_DEBUG;
delete process.env.TAKT_LOGGING_TRACE;
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
}); });
@ -583,6 +589,16 @@ describe('isVerboseMode', () => {
} else { } else {
process.env.TAKT_VERBOSE = originalTaktVerbose; process.env.TAKT_VERBOSE = originalTaktVerbose;
} }
if (originalTaktLoggingDebug === undefined) {
delete process.env.TAKT_LOGGING_DEBUG;
} else {
process.env.TAKT_LOGGING_DEBUG = originalTaktLoggingDebug;
}
if (originalTaktLoggingTrace === undefined) {
delete process.env.TAKT_LOGGING_TRACE;
} else {
process.env.TAKT_LOGGING_TRACE = originalTaktLoggingTrace;
}
if (existsSync(testDir)) { if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });
@ -629,6 +645,66 @@ describe('isVerboseMode', () => {
expect(isVerboseMode(testDir)).toBe(false); expect(isVerboseMode(testDir)).toBe(false);
}); });
it('should return true when global logging.debug is enabled', () => {
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(
join(globalConfigDir, 'config.yaml'),
[
'language: en',
'logging:',
' debug: true',
].join('\n'),
'utf-8',
);
expect(isVerboseMode(testDir)).toBe(true);
});
it('should return true when global logging.trace is enabled', () => {
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(
join(globalConfigDir, 'config.yaml'),
[
'language: en',
'logging:',
' trace: true',
].join('\n'),
'utf-8',
);
expect(isVerboseMode(testDir)).toBe(true);
});
it('should return true when TAKT_LOGGING_DEBUG=true is set', () => {
process.env.TAKT_LOGGING_DEBUG = 'true';
expect(isVerboseMode(testDir)).toBe(true);
});
it('should return true when TAKT_LOGGING_TRACE=true is set', () => {
process.env.TAKT_LOGGING_TRACE = 'true';
expect(isVerboseMode(testDir)).toBe(true);
});
it('should return true when global logging.level is debug', () => {
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(
join(globalConfigDir, 'config.yaml'),
[
'language: en',
'logging:',
' level: debug',
].join('\n'),
'utf-8',
);
expect(isVerboseMode(testDir)).toBe(true);
});
it('should prioritize TAKT_VERBOSE over project and global config', () => { it('should prioritize TAKT_VERBOSE over project and global config', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });

View File

@ -24,6 +24,7 @@ const {
loadGlobalConfig, loadGlobalConfig,
saveGlobalConfig, saveGlobalConfig,
invalidateGlobalConfigCache, invalidateGlobalConfigCache,
loadGlobalMigratedProjectLocalFallback,
} = await import('../infra/config/global/globalConfig.js'); } = await import('../infra/config/global/globalConfig.js');
const { getGlobalConfigPath } = await import('../infra/config/paths.js'); const { getGlobalConfigPath } = await import('../infra/config/paths.js');
@ -493,43 +494,271 @@ describe('loadGlobalConfig', () => {
}); });
}); });
it('should load observability.provider_events config from config.yaml', () => { it('should load logging config from config.yaml', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync( writeFileSync(
getGlobalConfigPath(), getGlobalConfigPath(),
[ [
'language: en', 'language: en',
'observability:', 'logging:',
' provider_events: false', ' provider_events: false',
].join('\n'), ].join('\n'),
'utf-8', 'utf-8',
); );
const config = loadGlobalConfig(); const config = loadGlobalConfig();
expect(config.observability).toEqual({ expect(config.logging).toEqual({
providerEvents: false, providerEvents: false,
}); });
}); });
it('should save and reload observability.provider_events config', () => { it('should load full logging config with all fields', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'logging:',
' level: debug',
' trace: true',
' debug: true',
' provider_events: true',
].join('\n'),
'utf-8',
);
const config = loadGlobalConfig();
expect(config.logging).toEqual({
level: 'debug',
trace: true,
debug: true,
providerEvents: true,
});
});
it('should save and reload logging config', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig(); const config = loadGlobalConfig();
config.observability = { config.logging = {
level: 'warn',
trace: false,
debug: true,
providerEvents: false, providerEvents: false,
}; };
saveGlobalConfig(config); saveGlobalConfig(config);
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig(); const reloaded = loadGlobalConfig();
expect(reloaded.observability).toEqual({ expect(reloaded.logging).toEqual({
level: 'warn',
trace: false,
debug: true,
providerEvents: false, providerEvents: false,
}); });
}); });
it('should save partial logging config (only provider_events)', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.logging = {
providerEvents: true,
};
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.logging).toEqual({
providerEvents: true,
});
});
describe('deprecated migration: observability → logging', () => {
it('should migrate observability.provider_events to logging.providerEvents', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'observability:',
' provider_events: true',
].join('\n'),
'utf-8',
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const config = loadGlobalConfig();
expect(config.logging?.providerEvents).toBe(true);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('observability'),
);
} finally {
warnSpy.mockRestore();
}
});
it('should not overwrite explicit logging.provider_events with observability value', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'logging:',
' provider_events: false',
'observability:',
' provider_events: true',
].join('\n'),
'utf-8',
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const config = loadGlobalConfig();
expect(config.logging?.providerEvents).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
it('should emit deprecation warning when observability is present', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'observability:',
' provider_events: false',
].join('\n'),
'utf-8',
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
loadGlobalConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Deprecated'),
);
} finally {
warnSpy.mockRestore();
}
});
});
describe('deprecated migration: log_level → logging.level', () => {
it('should migrate log_level to logging.level', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'log_level: warn',
].join('\n'),
'utf-8',
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const config = loadGlobalConfig();
expect(config.logging?.level).toBe('warn');
expect(warnSpy).toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
it('should prefer logging.level over legacy log_level', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'logging:',
' level: info',
'log_level: warn',
].join('\n'),
'utf-8',
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const config = loadGlobalConfig();
expect(config.logging?.level).toBe('info');
} finally {
warnSpy.mockRestore();
}
});
});
describe('logging.level → logLevel fallback', () => {
it('should use logging.level as logLevel fallback when legacy log_level is absent', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'logging:',
' level: warn',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
const fallback = loadGlobalMigratedProjectLocalFallback();
expect(fallback.logLevel).toBe('warn');
});
it('should prefer logging.level over legacy log_level', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'log_level: debug',
'logging:',
' level: warn',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
const fallback = loadGlobalMigratedProjectLocalFallback();
expect(fallback.logLevel).toBe('warn');
});
it('should fall back to legacy log_level when logging.level is absent', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'log_level: debug',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
const fallback = loadGlobalMigratedProjectLocalFallback();
expect(fallback.logLevel).toBe('debug');
});
});
it('should save and reload notification_sound_events config', () => { it('should save and reload notification_sound_events config', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });

View File

@ -118,4 +118,20 @@ piece_overrides:
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']); expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
}); });
}); });
describe('security hardening', () => {
it('should reject forbidden keys that can cause prototype pollution', () => {
const configContent = `
logging:
level: info
__proto__:
polluted: true
`;
writeFileSync(testConfigPath, configContent, 'utf-8');
const manager = GlobalConfigManager.getInstance();
expect(() => manager.load()).toThrow(/forbidden key "__proto__"/i);
expect(({} as Record<string, unknown>)['polluted']).toBeUndefined();
});
});
}); });

View File

@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest';
import { migrateDeprecatedGlobalConfigKeys } from '../infra/config/global/globalConfigLegacyMigration.js';
describe('migrateDeprecatedGlobalConfigKeys', () => {
it('should return migrated config without mutating input object', () => {
const rawConfig: Record<string, unknown> = {
log_level: 'warn',
observability: {
provider_events: true,
},
};
const originalSnapshot = JSON.parse(JSON.stringify(rawConfig)) as Record<string, unknown>;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const migrated = migrateDeprecatedGlobalConfigKeys(rawConfig);
expect(migrated.migratedLogLevel).toBe('warn');
expect(migrated.migratedConfig).toEqual({
logging: {
level: 'warn',
provider_events: true,
},
});
expect(rawConfig).toEqual(originalSnapshot);
} finally {
warnSpy.mockRestore();
}
});
});

View File

@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
function readModule(path: string): string {
return readFileSync(new URL(path, import.meta.url), 'utf-8');
}
describe('core/models public type-name boundary', () => {
it('should expose LoggingConfig from index barrel', () => {
const source = readModule('../core/models/index.ts');
expect(source).toMatch(/\bLoggingConfig\b/);
});
it('should not expose legacy ObservabilityConfig from index barrel', () => {
const source = readModule('../core/models/index.ts');
expect(source).not.toMatch(/\bObservabilityConfig\b/);
});
it('should expose LoggingConfig exactly once in index barrel exports', () => {
const source = readModule('../core/models/index.ts');
const matches = source.match(/\bLoggingConfig\b/g) ?? [];
expect(matches).toHaveLength(1);
});
});

View File

@ -578,18 +578,69 @@ describe('GlobalConfigSchema', () => {
const result = GlobalConfigSchema.parse(config); const result = GlobalConfigSchema.parse(config);
expect(result.provider).toBe('claude'); expect(result.provider).toBe('claude');
expect(result.observability).toBeUndefined(); expect(result.logging).toBeUndefined();
}); });
it('should accept valid config', () => { it('should accept valid logging config', () => {
const config = {
logging: {
provider_events: false,
},
};
const result = GlobalConfigSchema.parse(config);
expect(result.logging?.provider_events).toBe(false);
});
it('should accept full logging config with all fields', () => {
const config = {
logging: {
level: 'debug',
trace: true,
debug: true,
provider_events: true,
},
};
const result = GlobalConfigSchema.parse(config);
expect(result.logging?.level).toBe('debug');
expect(result.logging?.trace).toBe(true);
expect(result.logging?.debug).toBe(true);
expect(result.logging?.provider_events).toBe(true);
});
it('should accept partial logging config', () => {
const config = {
logging: {
level: 'warn',
},
};
const result = GlobalConfigSchema.parse(config);
expect(result.logging?.level).toBe('warn');
expect(result.logging?.trace).toBeUndefined();
expect(result.logging?.debug).toBeUndefined();
expect(result.logging?.provider_events).toBeUndefined();
});
it('should reject invalid logging level', () => {
const config = {
logging: {
level: 'verbose',
},
};
expect(() => GlobalConfigSchema.parse(config)).toThrow();
});
it('should reject observability key (strict schema rejects unknown keys)', () => {
const config = { const config = {
observability: { observability: {
provider_events: false, provider_events: false,
}, },
}; };
const result = GlobalConfigSchema.parse(config); expect(() => GlobalConfigSchema.parse(config)).toThrow();
expect(result.observability?.provider_events).toBe(false);
}); });
it('should parse global provider object block', () => { it('should parse global provider object block', () => {

View File

@ -87,7 +87,7 @@ vi.mock('../infra/config/index.js', () => ({
runtime: undefined, runtime: undefined,
preventSleep: false, preventSleep: false,
model: undefined, model: undefined,
observability: undefined, logging: undefined,
}), }),
saveSessionState: vi.fn(), saveSessionState: vi.fn(),
ensureDir: vi.fn(), ensureDir: vi.fn(),

View File

@ -102,7 +102,7 @@ vi.mock('../infra/config/index.js', () => ({
runtime: undefined, runtime: undefined,
preventSleep: false, preventSleep: false,
model: undefined, model: undefined,
observability: undefined, logging: undefined,
}), }),
saveSessionState: vi.fn(), saveSessionState: vi.fn(),
ensureDir: vi.fn(), ensureDir: vi.fn(),

View File

@ -84,7 +84,7 @@ vi.mock('../infra/config/index.js', () => ({
runtime: undefined, runtime: undefined,
preventSleep: false, preventSleep: false,
model: undefined, model: undefined,
observability: undefined, logging: undefined,
}), }),
saveSessionState: vi.fn(), saveSessionState: vi.fn(),
ensureDir: vi.fn(), ensureDir: vi.fn(),
@ -165,7 +165,7 @@ const defaultResolvedConfigValues = {
runtime: undefined, runtime: undefined,
preventSleep: false, preventSleep: false,
model: undefined, model: undefined,
observability: undefined, logging: undefined,
analytics: undefined, analytics: undefined,
}; };
@ -274,6 +274,19 @@ describe('executePiece session loading', () => {
expect(mockInfo).toHaveBeenCalledWith('Model: (default)'); expect(mockInfo).toHaveBeenCalledWith('Model: (default)');
}); });
it('should resolve logging config from piece config values', async () => {
await executePiece(makeConfig(), 'task', '/tmp/project', {
projectCwd: '/tmp/project',
});
const calls = vi.mocked(resolvePieceConfigValues).mock.calls;
expect(calls).toHaveLength(1);
const keys = calls[0]?.[1];
expect(Array.isArray(keys)).toBe(true);
expect(keys).toContain('logging');
expect(keys).not.toContain('observability');
});
it('should log configured model from global/project settings when movement model is unresolved', async () => { it('should log configured model from global/project settings when movement model is unresolved', async () => {
vi.mocked(resolvePieceConfigValues).mockReturnValue({ vi.mocked(resolvePieceConfigValues).mockReturnValue({
...defaultResolvedConfigValues, ...defaultResolvedConfigValues,

View File

@ -23,15 +23,22 @@ describe('providerEventLogger', () => {
it('should disable provider events by default', () => { it('should disable provider events by default', () => {
expect(isProviderEventsEnabled()).toBe(false); expect(isProviderEventsEnabled()).toBe(false);
expect(isProviderEventsEnabled({})).toBe(false); expect(isProviderEventsEnabled({})).toBe(false);
expect(isProviderEventsEnabled({ observability: {} })).toBe(false); expect(isProviderEventsEnabled({ logging: {} })).toBe(false);
}); });
it('should enable provider events only when explicitly true', () => { it('should enable provider events only when explicitly true', () => {
expect(isProviderEventsEnabled({ observability: { providerEvents: true } })).toBe(true); expect(isProviderEventsEnabled({ logging: { providerEvents: true } })).toBe(true);
}); });
it('should disable provider events only when explicitly false', () => { it('should disable provider events only when explicitly false', () => {
expect(isProviderEventsEnabled({ observability: { providerEvents: false } })).toBe(false); expect(isProviderEventsEnabled({ logging: { providerEvents: false } })).toBe(false);
});
it('should not enable provider events from legacy observability key', () => {
const legacyOnlyConfig = {
observability: { providerEvents: true },
} as unknown as Parameters<typeof isProviderEventsEnabled>[0];
expect(isProviderEventsEnabled(legacyOnlyConfig)).toBe(false);
}); });
it('should write normalized JSONL records when enabled', () => { it('should write normalized JSONL records when enabled', () => {
@ -155,6 +162,31 @@ describe('providerEventLogger', () => {
expect(parsed.data.text).toContain('...[truncated]'); expect(parsed.data.text).toContain('...[truncated]');
}); });
it('should report file write failures to stderr only once', () => {
const logger = createProviderEventLogger({
logsDir: join(tempDir, 'missing', 'nested'),
sessionId: 'session-err',
runId: 'run-err',
provider: 'claude',
movement: 'plan',
enabled: true,
});
const original = vi.fn();
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
const wrapped = logger.wrapCallback(original);
wrapped({ type: 'text', data: { text: 'first' } });
wrapped({ type: 'text', data: { text: 'second' } });
expect(original).toHaveBeenCalledTimes(2);
expect(stderrSpy).toHaveBeenCalledTimes(1);
expect(stderrSpy.mock.calls[0]?.[0]).toContain('Failed to write provider event log');
} finally {
stderrSpy.mockRestore();
}
});
it('should write init event records with typed data objects', () => { it('should write init event records with typed data objects', () => {
const logger = createProviderEventLogger({ const logger = createProviderEventLogger({
logsDir: tempDir, logsDir: tempDir,

View File

@ -92,6 +92,26 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
}); });
}); });
describe('logLevel migration', () => {
it('should resolve logLevel from global logging.level after migration', () => {
writeFileSync(
globalConfigPath,
[
'language: en',
'logging:',
' level: warn',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({
value: 'warn',
source: 'global',
});
});
});
describe('project-local priority for migrated keys', () => { describe('project-local priority for migrated keys', () => {
it.each([ it.each([
{ {

View File

@ -27,7 +27,7 @@ export type {
PieceConfig, PieceConfig,
PieceState, PieceState,
CustomAgentConfig, CustomAgentConfig,
ObservabilityConfig, LoggingConfig,
Language, Language,
PipelineConfig, PipelineConfig,
ProjectConfig, ProjectConfig,

View File

@ -35,8 +35,14 @@ export interface CustomAgentConfig {
claudeSkill?: string; claudeSkill?: string;
} }
/** Observability configuration for runtime event logs */ /** Logging configuration for runtime output */
export interface ObservabilityConfig { export interface LoggingConfig {
/** Log level for global output behavior */
level?: 'debug' | 'info' | 'warn' | 'error';
/** Enable trace logging */
trace?: boolean;
/** Enable debug logging */
debug?: boolean;
/** Enable provider stream event logging (default: false when undefined) */ /** Enable provider stream event logging (default: false when undefined) */
providerEvents?: boolean; providerEvents?: boolean;
} }
@ -93,7 +99,7 @@ export interface PersistedGlobalConfig {
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string; model?: string;
/** @globalOnly */ /** @globalOnly */
observability?: ObservabilityConfig; logging?: LoggingConfig;
analytics?: AnalyticsConfig; analytics?: AnalyticsConfig;
/** @globalOnly */ /** @globalOnly */
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */

View File

@ -460,7 +460,10 @@ export const CustomAgentConfigSchema = z.object({
{ message: 'Agent must have prompt_file, prompt, claude_agent, or claude_skill' } { message: 'Agent must have prompt_file, prompt, claude_agent, or claude_skill' }
); );
export const ObservabilityConfigSchema = z.object({ export const LoggingConfigSchema = z.object({
level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
trace: z.boolean().optional(),
debug: z.boolean().optional(),
provider_events: z.boolean().optional(), provider_events: z.boolean().optional(),
}); });
@ -500,7 +503,7 @@ export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
provider: ProviderReferenceSchema.optional().default('claude'), provider: ProviderReferenceSchema.optional().default('claude'),
model: z.string().optional(), model: z.string().optional(),
observability: ObservabilityConfigSchema.optional(), logging: LoggingConfigSchema.optional(),
analytics: AnalyticsConfigSchema.optional(), analytics: AnalyticsConfigSchema.optional(),
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktree_dir: z.string().optional(), worktree_dir: z.string().optional(),

View File

@ -63,7 +63,7 @@ export type {
export type { export type {
PersonaProviderEntry, PersonaProviderEntry,
CustomAgentConfig, CustomAgentConfig,
ObservabilityConfig, LoggingConfig,
Language, Language,
PipelineConfig, PipelineConfig,
ProjectConfig, ProjectConfig,

View File

@ -1,7 +1,3 @@
/**
* Piece execution logic
*/
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { PieceEngine, createDenyAskUserQuestionHandler } from '../../../core/piece/index.js'; import { PieceEngine, createDenyAskUserQuestionHandler } from '../../../core/piece/index.js';
@ -28,33 +24,12 @@ import { AnalyticsEmitter } from './analyticsEmitter.js';
import { createOutputFns, createPrefixedStreamHandler } from './outputFns.js'; import { createOutputFns, createPrefixedStreamHandler } from './outputFns.js';
import { RunMetaManager } from './runMeta.js'; import { RunMetaManager } from './runMeta.js';
import { createIterationLimitHandler, createUserInputHandler } from './iterationLimitHandler.js'; import { createIterationLimitHandler, createUserInputHandler } from './iterationLimitHandler.js';
import { assertTaskPrefixPair, truncate, formatElapsedTime } from './pieceExecutionUtils.js';
export type { PieceExecutionResult, PieceExecutionOptions }; export type { PieceExecutionResult, PieceExecutionOptions };
const log = createLogger('piece'); const log = createLogger('piece');
function assertTaskPrefixPair(
taskPrefix: string | undefined,
taskColorIndex: number | undefined,
): void {
if ((taskPrefix != null) !== (taskColorIndex != null)) {
throw new Error('taskPrefix and taskColorIndex must be provided together');
}
}
function truncate(str: string, maxLength: number): string {
return str.length <= maxLength ? str : str.slice(0, maxLength) + '...';
}
function formatElapsedTime(startTime: string, endTime: string): string {
const elapsedSec = (new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000;
if (elapsedSec < 60) return `${elapsedSec.toFixed(1)}s`;
return `${Math.floor(elapsedSec / 60)}m ${Math.floor(elapsedSec % 60)}s`;
}
/**
* Execute a piece and handle all events
*/
export async function executePiece( export async function executePiece(
pieceConfig: PieceConfig, pieceConfig: PieceConfig,
task: string, task: string,
@ -100,7 +75,7 @@ export async function executePiece(
const isWorktree = cwd !== projectCwd; const isWorktree = cwd !== projectCwd;
const globalConfig = resolvePieceConfigValues( const globalConfig = resolvePieceConfigValues(
projectCwd, projectCwd,
['notificationSound', 'notificationSoundEvents', 'provider', 'runtime', 'preventSleep', 'model', 'observability', 'analytics'], ['notificationSound', 'notificationSoundEvents', 'provider', 'runtime', 'preventSleep', 'model', 'logging', 'analytics'],
); );
const shouldNotify = globalConfig.notificationSound !== false; const shouldNotify = globalConfig.notificationSound !== false;
const nse = globalConfig.notificationSoundEvents; const nse = globalConfig.notificationSoundEvents;

View File

@ -0,0 +1,20 @@
export function assertTaskPrefixPair(
taskPrefix: string | undefined,
taskColorIndex: number | undefined,
): void {
if ((taskPrefix != null) !== (taskColorIndex != null)) {
throw new Error('taskPrefix and taskColorIndex must be provided together');
}
}
export function truncate(value: string, maxLength: number): string {
return value.length <= maxLength ? value : value.slice(0, maxLength) + '...';
}
export function formatElapsedTime(startTime: string, endTime: string): string {
const elapsedSec = (new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000;
if (elapsedSec < 60) {
return `${elapsedSec.toFixed(1)}s`;
}
return `${Math.floor(elapsedSec / 60)}m ${Math.floor(elapsedSec % 60)}s`;
}

View File

@ -75,12 +75,38 @@ function applyEnvOverrides(target: Record<string, unknown>, specs: readonly EnvS
} }
} }
function applyLegacyGlobalLoggingEnvOverrides(target: Record<string, unknown>): void {
const nextLogging = process.env.TAKT_LOGGING;
const nextLoggingLevel = process.env.TAKT_LOGGING_LEVEL;
const legacyLogLevel = process.env.TAKT_LOG_LEVEL;
if (legacyLogLevel !== undefined && nextLoggingLevel === undefined && nextLogging === undefined) {
console.warn('Deprecated: "TAKT_LOG_LEVEL" is deprecated. Use "TAKT_LOGGING_LEVEL" instead.');
setNested(target, 'logging.level', parseEnvValue('TAKT_LOG_LEVEL', legacyLogLevel, 'string'));
}
const nextLoggingProviderEvents = process.env.TAKT_LOGGING_PROVIDER_EVENTS;
const legacyProviderEvents = process.env.TAKT_OBSERVABILITY_PROVIDER_EVENTS;
if (legacyProviderEvents !== undefined && nextLoggingProviderEvents === undefined && nextLogging === undefined) {
console.warn(
'Deprecated: "TAKT_OBSERVABILITY_PROVIDER_EVENTS" is deprecated. Use "TAKT_LOGGING_PROVIDER_EVENTS" instead.',
);
setNested(
target,
'logging.provider_events',
parseEnvValue('TAKT_OBSERVABILITY_PROVIDER_EVENTS', legacyProviderEvents, 'boolean'),
);
}
}
const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'language', type: 'string' }, { path: 'language', type: 'string' },
{ path: 'provider', type: 'string' }, { path: 'provider', type: 'string' },
{ path: 'model', type: 'string' }, { path: 'model', type: 'string' },
{ path: 'observability', type: 'json' }, { path: 'logging', type: 'json' },
{ path: 'observability.provider_events', type: 'boolean' }, { path: 'logging.level', type: 'string' },
{ path: 'logging.trace', type: 'boolean' },
{ path: 'logging.debug', type: 'boolean' },
{ path: 'logging.provider_events', type: 'boolean' },
{ path: 'analytics', type: 'json' }, { path: 'analytics', type: 'json' },
{ path: 'analytics.enabled', type: 'boolean' }, { path: 'analytics.enabled', type: 'boolean' },
{ path: 'analytics.events_path', type: 'string' }, { path: 'analytics.events_path', type: 'string' },
@ -155,6 +181,7 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void { export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {
applyEnvOverrides(target, GLOBAL_ENV_SPECS); applyEnvOverrides(target, GLOBAL_ENV_SPECS);
applyLegacyGlobalLoggingEnvOverrides(target);
} }
export function applyProjectConfigEnvOverrides(target: Record<string, unknown>): void { export function applyProjectConfigEnvOverrides(target: Record<string, unknown>): void {

View File

@ -8,11 +8,7 @@ import {
} from '../providerReference.js'; } from '../providerReference.js';
import { import {
normalizeProviderProfiles, normalizeProviderProfiles,
denormalizeProviderProfiles,
normalizePieceOverrides, normalizePieceOverrides,
denormalizePieceOverrides,
denormalizeProviderOptions,
normalizeRuntime,
} from '../configNormalizers.js'; } from '../configNormalizers.js';
import { getGlobalConfigPath } from '../paths.js'; import { getGlobalConfigPath } from '../paths.js';
import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js'; import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js';
@ -23,7 +19,20 @@ import {
removeMigratedProjectLocalKeys, removeMigratedProjectLocalKeys,
type GlobalMigratedProjectLocalFallback, type GlobalMigratedProjectLocalFallback,
} from './globalMigratedProjectLocalFallback.js'; } from './globalMigratedProjectLocalFallback.js';
import {
sanitizeConfigValue,
migrateDeprecatedGlobalConfigKeys,
} from './globalConfigLegacyMigration.js';
import { serializeGlobalConfig } from './globalConfigSerializer.js';
export { validateCliPath } from './cliPathValidator.js'; export { validateCliPath } from './cliPathValidator.js';
function getRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
type ProviderType = NonNullable<PersistedGlobalConfig['provider']>; type ProviderType = NonNullable<PersistedGlobalConfig['provider']>;
type RawProviderReference = ConfigProviderReference<ProviderType>; type RawProviderReference = ConfigProviderReference<ProviderType>;
export class GlobalConfigManager { export class GlobalConfigManager {
@ -59,15 +68,25 @@ export class GlobalConfigManager {
const content = readFileSync(configPath, 'utf-8'); const content = readFileSync(configPath, 'utf-8');
const parsedRaw = parseYaml(content); const parsedRaw = parseYaml(content);
if (parsedRaw && typeof parsedRaw === 'object' && !Array.isArray(parsedRaw)) { if (parsedRaw && typeof parsedRaw === 'object' && !Array.isArray(parsedRaw)) {
Object.assign(rawConfig, parsedRaw as Record<string, unknown>); const sanitizedParsedRaw = getRecord(sanitizeConfigValue(parsedRaw, 'config'));
if (!sanitizedParsedRaw) {
throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.');
}
for (const [key, value] of Object.entries(sanitizedParsedRaw)) {
rawConfig[key] = value;
}
} else if (parsedRaw != null) { } else if (parsedRaw != null) {
throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.'); throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.');
} }
} }
applyGlobalConfigEnvOverrides(rawConfig); applyGlobalConfigEnvOverrides(rawConfig);
const migratedProjectLocalFallback = extractMigratedProjectLocalFallback(rawConfig); const { migratedConfig, migratedLogLevel } = migrateDeprecatedGlobalConfigKeys(rawConfig);
const schemaInput = { ...rawConfig }; const migratedProjectLocalFallback = extractMigratedProjectLocalFallback({
...migratedConfig,
...(migratedLogLevel !== undefined ? { log_level: migratedLogLevel } : {}),
});
const schemaInput = { ...migratedConfig };
removeMigratedProjectLocalKeys(schemaInput); removeMigratedProjectLocalKeys(schemaInput);
const parsed = GlobalConfigSchema.parse(schemaInput); const parsed = GlobalConfigSchema.parse(schemaInput);
@ -80,8 +99,11 @@ export class GlobalConfigManager {
language: parsed.language, language: parsed.language,
provider: normalizedProvider.provider, provider: normalizedProvider.provider,
model: normalizedProvider.model, model: normalizedProvider.model,
observability: parsed.observability ? { logging: parsed.logging ? {
providerEvents: parsed.observability.provider_events, level: parsed.logging.level,
trace: parsed.logging.trace,
debug: parsed.logging.debug,
providerEvents: parsed.logging.provider_events,
} : undefined, } : undefined,
analytics: parsed.analytics ? { analytics: parsed.analytics ? {
enabled: parsed.analytics.enabled, enabled: parsed.analytics.enabled,
@ -110,7 +132,9 @@ export class GlobalConfigManager {
pieceCategoriesFile: parsed.piece_categories_file, pieceCategoriesFile: parsed.piece_categories_file,
providerOptions: normalizedProvider.providerOptions, providerOptions: normalizedProvider.providerOptions,
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined), providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
runtime: normalizeRuntime(parsed.runtime), runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0
? { prepare: [...new Set(parsed.runtime.prepare)] }
: undefined,
preventSleep: parsed.prevent_sleep, preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound, notificationSound: parsed.notification_sound,
notificationSoundEvents: parsed.notification_sound_events ? { notificationSoundEvents: parsed.notification_sound_events ? {
@ -140,136 +164,7 @@ export class GlobalConfigManager {
save(config: PersistedGlobalConfig): void { save(config: PersistedGlobalConfig): void {
const configPath = getGlobalConfigPath(); const configPath = getGlobalConfigPath();
const raw: Record<string, unknown> = { const raw = serializeGlobalConfig(config);
language: config.language,
provider: config.provider,
};
if (config.model) {
raw.model = config.model;
}
if (config.observability && config.observability.providerEvents !== undefined) {
raw.observability = {
provider_events: config.observability.providerEvents,
};
}
if (config.analytics) {
const analyticsRaw: Record<string, unknown> = {};
if (config.analytics.enabled !== undefined) analyticsRaw.enabled = config.analytics.enabled;
if (config.analytics.eventsPath) analyticsRaw.events_path = config.analytics.eventsPath;
if (config.analytics.retentionDays !== undefined) analyticsRaw.retention_days = config.analytics.retentionDays;
if (Object.keys(analyticsRaw).length > 0) {
raw.analytics = analyticsRaw;
}
}
if (config.worktreeDir) {
raw.worktree_dir = config.worktreeDir;
}
if (config.autoPr !== undefined) {
raw.auto_pr = config.autoPr;
}
if (config.draftPr !== undefined) {
raw.draft_pr = config.draftPr;
}
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
raw.disabled_builtins = config.disabledBuiltins;
}
if (config.enableBuiltinPieces !== undefined) {
raw.enable_builtin_pieces = config.enableBuiltinPieces;
}
if (config.anthropicApiKey) {
raw.anthropic_api_key = config.anthropicApiKey;
}
if (config.openaiApiKey) {
raw.openai_api_key = config.openaiApiKey;
}
if (config.geminiApiKey) {
raw.gemini_api_key = config.geminiApiKey;
}
if (config.googleApiKey) {
raw.google_api_key = config.googleApiKey;
}
if (config.groqApiKey) {
raw.groq_api_key = config.groqApiKey;
}
if (config.openrouterApiKey) {
raw.openrouter_api_key = config.openrouterApiKey;
}
if (config.codexCliPath) {
raw.codex_cli_path = config.codexCliPath;
}
if (config.claudeCliPath) {
raw.claude_cli_path = config.claudeCliPath;
}
if (config.cursorCliPath) {
raw.cursor_cli_path = config.cursorCliPath;
}
if (config.copilotCliPath) {
raw.copilot_cli_path = config.copilotCliPath;
}
if (config.copilotGithubToken) {
raw.copilot_github_token = config.copilotGithubToken;
}
if (config.opencodeApiKey) {
raw.opencode_api_key = config.opencodeApiKey;
}
if (config.cursorApiKey) {
raw.cursor_api_key = config.cursorApiKey;
}
if (config.bookmarksFile) {
raw.bookmarks_file = config.bookmarksFile;
}
if (config.pieceCategoriesFile) {
raw.piece_categories_file = config.pieceCategoriesFile;
}
const rawProviderOptions = denormalizeProviderOptions(config.providerOptions);
if (rawProviderOptions) {
raw.provider_options = rawProviderOptions;
}
const rawProviderProfiles = denormalizeProviderProfiles(config.providerProfiles);
if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) {
raw.provider_profiles = rawProviderProfiles;
}
const normalizedRuntime = normalizeRuntime(config.runtime);
if (normalizedRuntime) {
raw.runtime = normalizedRuntime;
}
if (config.preventSleep !== undefined) {
raw.prevent_sleep = config.preventSleep;
}
if (config.notificationSound !== undefined) {
raw.notification_sound = config.notificationSound;
}
if (config.notificationSoundEvents) {
const eventRaw: Record<string, unknown> = {};
if (config.notificationSoundEvents.iterationLimit !== undefined) {
eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit;
}
if (config.notificationSoundEvents.pieceComplete !== undefined) {
eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete;
}
if (config.notificationSoundEvents.pieceAbort !== undefined) {
eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort;
}
if (config.notificationSoundEvents.runComplete !== undefined) {
eventRaw.run_complete = config.notificationSoundEvents.runComplete;
}
if (config.notificationSoundEvents.runAbort !== undefined) {
eventRaw.run_abort = config.notificationSoundEvents.runAbort;
}
if (Object.keys(eventRaw).length > 0) {
raw.notification_sound_events = eventRaw;
}
}
if (config.autoFetch) {
raw.auto_fetch = config.autoFetch;
}
if (config.baseBranch) {
raw.base_branch = config.baseBranch;
}
const denormalizedPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
if (denormalizedPieceOverrides) {
raw.piece_overrides = denormalizedPieceOverrides;
}
writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
this.invalidateCache(); this.invalidateCache();
invalidateAllResolvedConfigCache(); invalidateAllResolvedConfigCache();

View File

@ -0,0 +1,99 @@
function getRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
const FORBIDDEN_CONFIG_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
export function sanitizeConfigValue(value: unknown, path: string): unknown {
if (Array.isArray(value)) {
return value.map((item, index) => sanitizeConfigValue(item, `${path}[${index}]`));
}
const record = getRecord(value);
if (!record) {
return value;
}
const sanitized: Record<string, unknown> = {};
for (const [key, nestedValue] of Object.entries(record)) {
if (FORBIDDEN_CONFIG_KEYS.has(key)) {
throw new Error(`Configuration error: forbidden key "${key}" at "${path}".`);
}
sanitized[key] = sanitizeConfigValue(nestedValue, `${path}.${key}`);
}
return sanitized;
}
type LegacyGlobalConfigMigrationResult = {
migratedConfig: Record<string, unknown>;
migratedLogLevel?: string;
};
export function migrateDeprecatedGlobalConfigKeys(rawConfig: Record<string, unknown>): LegacyGlobalConfigMigrationResult {
const migratedConfig: Record<string, unknown> = { ...rawConfig };
const hasLegacyLogLevel = Object.prototype.hasOwnProperty.call(rawConfig, 'log_level');
const legacyLogLevel = rawConfig.log_level;
const hasLegacyObservability = Object.prototype.hasOwnProperty.call(rawConfig, 'observability');
const observability = getRecord(rawConfig.observability);
const initialLogging = getRecord(rawConfig.logging);
let migratedLogging = initialLogging ? { ...initialLogging } : undefined;
if (hasLegacyObservability) {
console.warn('Deprecated: "observability" is deprecated. Use "logging" instead.');
if (observability) {
const observabilityProviderEvents = observability.provider_events;
if (observabilityProviderEvents !== undefined) {
const hasExplicitProviderEvents = migratedLogging
? Object.prototype.hasOwnProperty.call(migratedLogging, 'provider_events')
: false;
if (!hasExplicitProviderEvents) {
migratedLogging = {
...(migratedLogging ?? {}),
provider_events: observabilityProviderEvents,
};
}
}
}
}
if (hasLegacyLogLevel) {
console.warn('Deprecated: "log_level" is deprecated. Use "logging.level" instead.');
}
const resolvedLoggingLevel = migratedLogging?.level;
const migratedLogLevel = typeof resolvedLoggingLevel === 'string'
? resolvedLoggingLevel
: hasLegacyLogLevel && typeof legacyLogLevel === 'string'
? legacyLogLevel
: undefined;
if (migratedLogLevel !== undefined) {
const hasExplicitLevel = migratedLogging
? Object.prototype.hasOwnProperty.call(migratedLogging, 'level')
: false;
if (!hasExplicitLevel) {
migratedLogging = {
...(migratedLogging ?? {}),
level: migratedLogLevel,
};
}
}
if (migratedLogging) {
migratedConfig.logging = migratedLogging;
}
if (hasLegacyObservability) {
delete migratedConfig.observability;
}
if (hasLegacyLogLevel) {
delete migratedConfig.log_level;
}
return {
migratedConfig,
migratedLogLevel,
};
}

View File

@ -0,0 +1,149 @@
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js';
import {
denormalizeProviderProfiles,
denormalizePieceOverrides,
denormalizeProviderOptions,
} from '../configNormalizers.js';
export function serializeGlobalConfig(config: PersistedGlobalConfig): Record<string, unknown> {
const raw: Record<string, unknown> = {
language: config.language,
provider: config.provider,
};
if (config.model) {
raw.model = config.model;
}
if (config.logging && (
config.logging.level !== undefined
|| config.logging.trace !== undefined
|| config.logging.debug !== undefined
|| config.logging.providerEvents !== undefined
)) {
raw.logging = {
...(config.logging.level !== undefined ? { level: config.logging.level } : {}),
...(config.logging.trace !== undefined ? { trace: config.logging.trace } : {}),
...(config.logging.debug !== undefined ? { debug: config.logging.debug } : {}),
...(config.logging.providerEvents !== undefined ? { provider_events: config.logging.providerEvents } : {}),
};
}
if (config.analytics) {
const analyticsRaw: Record<string, unknown> = {};
if (config.analytics.enabled !== undefined) analyticsRaw.enabled = config.analytics.enabled;
if (config.analytics.eventsPath) analyticsRaw.events_path = config.analytics.eventsPath;
if (config.analytics.retentionDays !== undefined) analyticsRaw.retention_days = config.analytics.retentionDays;
if (Object.keys(analyticsRaw).length > 0) {
raw.analytics = analyticsRaw;
}
}
if (config.worktreeDir) {
raw.worktree_dir = config.worktreeDir;
}
if (config.autoPr !== undefined) {
raw.auto_pr = config.autoPr;
}
if (config.draftPr !== undefined) {
raw.draft_pr = config.draftPr;
}
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
raw.disabled_builtins = config.disabledBuiltins;
}
if (config.enableBuiltinPieces !== undefined) {
raw.enable_builtin_pieces = config.enableBuiltinPieces;
}
if (config.anthropicApiKey) {
raw.anthropic_api_key = config.anthropicApiKey;
}
if (config.openaiApiKey) {
raw.openai_api_key = config.openaiApiKey;
}
if (config.geminiApiKey) {
raw.gemini_api_key = config.geminiApiKey;
}
if (config.googleApiKey) {
raw.google_api_key = config.googleApiKey;
}
if (config.groqApiKey) {
raw.groq_api_key = config.groqApiKey;
}
if (config.openrouterApiKey) {
raw.openrouter_api_key = config.openrouterApiKey;
}
if (config.codexCliPath) {
raw.codex_cli_path = config.codexCliPath;
}
if (config.claudeCliPath) {
raw.claude_cli_path = config.claudeCliPath;
}
if (config.cursorCliPath) {
raw.cursor_cli_path = config.cursorCliPath;
}
if (config.copilotCliPath) {
raw.copilot_cli_path = config.copilotCliPath;
}
if (config.copilotGithubToken) {
raw.copilot_github_token = config.copilotGithubToken;
}
if (config.opencodeApiKey) {
raw.opencode_api_key = config.opencodeApiKey;
}
if (config.cursorApiKey) {
raw.cursor_api_key = config.cursorApiKey;
}
if (config.bookmarksFile) {
raw.bookmarks_file = config.bookmarksFile;
}
if (config.pieceCategoriesFile) {
raw.piece_categories_file = config.pieceCategoriesFile;
}
const rawProviderOptions = denormalizeProviderOptions(config.providerOptions);
if (rawProviderOptions) {
raw.provider_options = rawProviderOptions;
}
const rawProviderProfiles = denormalizeProviderProfiles(config.providerProfiles);
if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) {
raw.provider_profiles = rawProviderProfiles;
}
if (config.runtime?.prepare && config.runtime.prepare.length > 0) {
raw.runtime = {
prepare: [...new Set(config.runtime.prepare)],
};
}
if (config.preventSleep !== undefined) {
raw.prevent_sleep = config.preventSleep;
}
if (config.notificationSound !== undefined) {
raw.notification_sound = config.notificationSound;
}
if (config.notificationSoundEvents) {
const eventRaw: Record<string, unknown> = {};
if (config.notificationSoundEvents.iterationLimit !== undefined) {
eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit;
}
if (config.notificationSoundEvents.pieceComplete !== undefined) {
eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete;
}
if (config.notificationSoundEvents.pieceAbort !== undefined) {
eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort;
}
if (config.notificationSoundEvents.runComplete !== undefined) {
eventRaw.run_complete = config.notificationSoundEvents.runComplete;
}
if (config.notificationSoundEvents.runAbort !== undefined) {
eventRaw.run_abort = config.notificationSoundEvents.runAbort;
}
if (Object.keys(eventRaw).length > 0) {
raw.notification_sound_events = eventRaw;
}
}
if (config.autoFetch) {
raw.auto_fetch = config.autoFetch;
}
if (config.baseBranch) {
raw.base_branch = config.baseBranch;
}
const denormalizedPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
if (denormalizedPieceOverrides) {
raw.piece_overrides = denormalizedPieceOverrides;
}
return raw;
}

View File

@ -1,5 +1,5 @@
import { resolveConfigValue } from '../resolveConfigValue.js'; import { isVerboseShortcutEnabled } from '../resolveConfigValue.js';
export function isVerboseMode(projectDir: string): boolean { export function isVerboseMode(projectDir: string): boolean {
return resolveConfigValue(projectDir, 'verbose'); return isVerboseShortcutEnabled(projectDir);
} }

View File

@ -75,6 +75,7 @@ const MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET = new Set(
); );
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = { const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
logLevel: { layers: ['local', 'global'] },
provider: { provider: {
layers: ['local', 'piece', 'global'], layers: ['local', 'piece', 'global'],
pieceValue: (pieceContext) => pieceContext?.provider, pieceValue: (pieceContext) => pieceContext?.provider,
@ -134,6 +135,10 @@ function getGlobalLayerValue<K extends ConfigParameterKey>(
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback, globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
key: K, key: K,
): LoadedConfig[K] | undefined { ): LoadedConfig[K] | undefined {
if (key === 'logLevel' && global.logging?.level !== undefined) {
return global.logging.level as LoadedConfig[K];
}
if (isMigratedProjectLocalConfigKey(key)) { if (isMigratedProjectLocalConfigKey(key)) {
return globalMigratedProjectLocalFallback[key] as LoadedConfig[K] | undefined; return globalMigratedProjectLocalFallback[key] as LoadedConfig[K] | undefined;
} }
@ -243,3 +248,20 @@ export function resolveConfigValues<K extends ConfigParameterKey>(
} }
return result; return result;
} }
export function isVerboseShortcutEnabled(
projectDir: string,
options?: ResolveConfigOptions,
): boolean {
const verbose = resolveConfigValue(projectDir, 'verbose', options);
if (verbose === true) {
return true;
}
const logging = resolveConfigValue(projectDir, 'logging', options);
if (logging?.debug === true || logging?.trace === true) {
return true;
}
return resolveConfigValue(projectDir, 'logLevel', options) === 'debug';
}

View File

@ -94,13 +94,19 @@ export function createProviderEventLogger(config: ProviderEventLoggerConfig): Pr
const filepath = join(config.logsDir, `${config.sessionId}-provider-events.jsonl`); const filepath = join(config.logsDir, `${config.sessionId}-provider-events.jsonl`);
let movement = config.movement; let movement = config.movement;
let provider = config.provider; let provider = config.provider;
let hasReportedWriteFailure = false;
const write = (event: StreamEvent): void => { const write = (event: StreamEvent): void => {
try { try {
const record = buildLogRecord(event, provider, movement, config.runId); const record = buildLogRecord(event, provider, movement, config.runId);
appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8'); appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8');
} catch { } catch (error) {
// Silently fail - observability logging should not interrupt main flow. if (hasReportedWriteFailure) {
return;
}
hasReportedWriteFailure = true;
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`[takt] Failed to write provider event log: ${message}\n`);
} }
}; };
@ -129,9 +135,9 @@ export function createProviderEventLogger(config: ProviderEventLoggerConfig): Pr
} }
export function isProviderEventsEnabled(config?: { export function isProviderEventsEnabled(config?: {
observability?: { logging?: {
providerEvents?: boolean; providerEvents?: boolean;
}; };
}): boolean { }): boolean {
return config?.observability?.providerEvents === true; return config?.logging?.providerEvents === true;
} }