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:
parent
289a0fb912
commit
69dd871404
15
CLAUDE.md
15
CLAUDE.md
@ -601,17 +601,24 @@ Implemented in `src/core/runtime/runtime-environment.ts`.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
**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 Notes
|
||||
|
||||
@ -20,7 +20,11 @@ language: en # UI language: en | ja
|
||||
# piece_abort: true
|
||||
# run_complete: true
|
||||
# run_abort: true
|
||||
# observability:
|
||||
# verbose: false # Shortcut: enable trace/debug and set logging.level=debug
|
||||
# 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)
|
||||
|
||||
@ -20,7 +20,11 @@ language: ja # 表示言語: ja | en
|
||||
# piece_abort: true
|
||||
# run_complete: 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イベントログを記録
|
||||
|
||||
# 認証情報(環境変数優先)
|
||||
|
||||
@ -12,7 +12,8 @@
|
||||
# ~/.takt/config.yaml
|
||||
language: en # UI 言語: 'en' または 'ja'
|
||||
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
|
||||
model: sonnet # デフォルトモデル(省略可、provider にそのまま渡される)
|
||||
branch_name_strategy: romaji # ブランチ名生成方式: 'romaji'(高速)または 'ai'(低速)
|
||||
@ -92,7 +93,7 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
|
||||
|-----------|------|---------|------|
|
||||
| `language` | `"en"` \| `"ja"` | `"en"` | UI 言語 |
|
||||
| `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 |
|
||||
| `model` | string | - | デフォルトモデル名(provider にそのまま渡される) |
|
||||
| `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
|
||||
# .takt/debug.yaml
|
||||
enabled: true
|
||||
logging:
|
||||
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
|
||||
# ~/.takt/config.yaml または .takt/config.yaml
|
||||
verbose: true
|
||||
```
|
||||
|
||||
```bash
|
||||
# env
|
||||
TAKT_VERBOSE=true
|
||||
```
|
||||
|
||||
@ -12,7 +12,8 @@ Configure TAKT defaults in `~/.takt/config.yaml`. This file is created automatic
|
||||
# ~/.takt/config.yaml
|
||||
language: en # UI language: 'en' or 'ja'
|
||||
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
|
||||
model: sonnet # Default model (optional, passed to provider as-is)
|
||||
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 |
|
||||
| `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 |
|
||||
| `model` | string | - | Default model name (passed to provider as-is) |
|
||||
| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | Branch name generation strategy |
|
||||
@ -434,22 +435,28 @@ pipeline:
|
||||
|
||||
### 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
|
||||
# .takt/debug.yaml
|
||||
enabled: true
|
||||
logging:
|
||||
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
|
||||
|
||||
Create an empty `.takt/verbose` file to enable verbose console output. This automatically enables debug logging.
|
||||
|
||||
Alternatively, set `verbose: true` in your config:
|
||||
Set `verbose: true` in your config:
|
||||
|
||||
```yaml
|
||||
# ~/.takt/config.yaml or .takt/config.yaml
|
||||
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`.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
applyGlobalConfigEnvOverrides,
|
||||
applyProjectConfigEnvOverrides,
|
||||
@ -52,7 +52,6 @@ describe('config env overrides', () => {
|
||||
});
|
||||
|
||||
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_VERBOSE = 'true';
|
||||
process.env.TAKT_CONCURRENCY = '3';
|
||||
@ -61,7 +60,6 @@ describe('config env overrides', () => {
|
||||
const raw: Record<string, unknown> = {};
|
||||
applyProjectConfigEnvOverrides(raw);
|
||||
|
||||
expect(raw.log_level).toBe('debug');
|
||||
expect(raw.model).toBe('gpt-5');
|
||||
expect(raw.verbose).toBe(true);
|
||||
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', () => {
|
||||
process.env.TAKT_CURSOR_API_KEY = 'cursor-key-from-env';
|
||||
process.env.TAKT_GEMINI_API_KEY = 'gemini-key-from-env';
|
||||
|
||||
@ -12,8 +12,18 @@ describe('config module file-size boundary', () => {
|
||||
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', () => {
|
||||
const lineCount = getLineCount('../infra/config/project/projectConfig.ts');
|
||||
expect(lineCount).toBeLessThanOrEqual(300);
|
||||
});
|
||||
|
||||
it('keeps pieceExecution.ts under 300 lines', () => {
|
||||
const lineCount = getLineCount('../features/tasks/execute/pieceExecution.ts');
|
||||
expect(lineCount).toBeLessThanOrEqual(300);
|
||||
});
|
||||
});
|
||||
|
||||
@ -561,14 +561,20 @@ describe('isVerboseMode', () => {
|
||||
let testDir: string;
|
||||
let originalTaktConfigDir: string | undefined;
|
||||
let originalTaktVerbose: string | undefined;
|
||||
let originalTaktLoggingDebug: string | undefined;
|
||||
let originalTaktLoggingTrace: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
|
||||
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');
|
||||
delete process.env.TAKT_VERBOSE;
|
||||
delete process.env.TAKT_LOGGING_DEBUG;
|
||||
delete process.env.TAKT_LOGGING_TRACE;
|
||||
invalidateGlobalConfigCache();
|
||||
});
|
||||
|
||||
@ -583,6 +589,16 @@ describe('isVerboseMode', () => {
|
||||
} else {
|
||||
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)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
@ -629,6 +645,66 @@ describe('isVerboseMode', () => {
|
||||
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', () => {
|
||||
const projectConfigDir = getProjectConfigDir(testDir);
|
||||
mkdirSync(projectConfigDir, { recursive: true });
|
||||
|
||||
@ -24,6 +24,7 @@ const {
|
||||
loadGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
invalidateGlobalConfigCache,
|
||||
loadGlobalMigratedProjectLocalFallback,
|
||||
} = await import('../infra/config/global/globalConfig.js');
|
||||
const { getGlobalConfigPath } = await import('../infra/config/paths.js');
|
||||
|
||||
@ -493,7 +494,143 @@ 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');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
[
|
||||
'language: en',
|
||||
'logging:',
|
||||
' provider_events: false',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
expect(config.logging).toEqual({
|
||||
providerEvents: false,
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
config.logging = {
|
||||
level: 'warn',
|
||||
trace: false,
|
||||
debug: true,
|
||||
providerEvents: false,
|
||||
};
|
||||
saveGlobalConfig(config);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.logging).toEqual({
|
||||
level: 'warn',
|
||||
trace: false,
|
||||
debug: true,
|
||||
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(
|
||||
@ -506,27 +643,119 @@ describe('loadGlobalConfig', () => {
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
expect(config.observability).toEqual({
|
||||
providerEvents: false,
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
loadGlobalConfig();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Deprecated'),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should save and reload observability.provider_events config', () => {
|
||||
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\n', 'utf-8');
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
[
|
||||
'language: en',
|
||||
'log_level: warn',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
config.observability = {
|
||||
providerEvents: false,
|
||||
};
|
||||
saveGlobalConfig(config);
|
||||
invalidateGlobalConfigCache();
|
||||
expect(config.logging?.level).toBe('warn');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.observability).toEqual({
|
||||
providerEvents: false,
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -118,4 +118,20 @@ piece_overrides:
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
30
src/__tests__/globalConfigLegacyMigration.test.ts
Normal file
30
src/__tests__/globalConfigLegacyMigration.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
24
src/__tests__/models-api-boundary.test.ts
Normal file
24
src/__tests__/models-api-boundary.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -578,18 +578,69 @@ describe('GlobalConfigSchema', () => {
|
||||
const result = GlobalConfigSchema.parse(config);
|
||||
|
||||
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 = {
|
||||
observability: {
|
||||
provider_events: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = GlobalConfigSchema.parse(config);
|
||||
expect(result.observability?.provider_events).toBe(false);
|
||||
expect(() => GlobalConfigSchema.parse(config)).toThrow();
|
||||
});
|
||||
|
||||
it('should parse global provider object block', () => {
|
||||
|
||||
@ -87,7 +87,7 @@ vi.mock('../infra/config/index.js', () => ({
|
||||
runtime: undefined,
|
||||
preventSleep: false,
|
||||
model: undefined,
|
||||
observability: undefined,
|
||||
logging: undefined,
|
||||
}),
|
||||
saveSessionState: vi.fn(),
|
||||
ensureDir: vi.fn(),
|
||||
|
||||
@ -102,7 +102,7 @@ vi.mock('../infra/config/index.js', () => ({
|
||||
runtime: undefined,
|
||||
preventSleep: false,
|
||||
model: undefined,
|
||||
observability: undefined,
|
||||
logging: undefined,
|
||||
}),
|
||||
saveSessionState: vi.fn(),
|
||||
ensureDir: vi.fn(),
|
||||
|
||||
@ -84,7 +84,7 @@ vi.mock('../infra/config/index.js', () => ({
|
||||
runtime: undefined,
|
||||
preventSleep: false,
|
||||
model: undefined,
|
||||
observability: undefined,
|
||||
logging: undefined,
|
||||
}),
|
||||
saveSessionState: vi.fn(),
|
||||
ensureDir: vi.fn(),
|
||||
@ -165,7 +165,7 @@ const defaultResolvedConfigValues = {
|
||||
runtime: undefined,
|
||||
preventSleep: false,
|
||||
model: undefined,
|
||||
observability: undefined,
|
||||
logging: undefined,
|
||||
analytics: undefined,
|
||||
};
|
||||
|
||||
@ -274,6 +274,19 @@ describe('executePiece session loading', () => {
|
||||
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 () => {
|
||||
vi.mocked(resolvePieceConfigValues).mockReturnValue({
|
||||
...defaultResolvedConfigValues,
|
||||
|
||||
@ -23,15 +23,22 @@ describe('providerEventLogger', () => {
|
||||
it('should disable provider events by default', () => {
|
||||
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', () => {
|
||||
expect(isProviderEventsEnabled({ observability: { providerEvents: true } })).toBe(true);
|
||||
expect(isProviderEventsEnabled({ logging: { providerEvents: true } })).toBe(true);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@ -155,6 +162,31 @@ describe('providerEventLogger', () => {
|
||||
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', () => {
|
||||
const logger = createProviderEventLogger({
|
||||
logsDir: tempDir,
|
||||
|
||||
@ -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', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@ -27,7 +27,7 @@ export type {
|
||||
PieceConfig,
|
||||
PieceState,
|
||||
CustomAgentConfig,
|
||||
ObservabilityConfig,
|
||||
LoggingConfig,
|
||||
Language,
|
||||
PipelineConfig,
|
||||
ProjectConfig,
|
||||
|
||||
@ -35,8 +35,14 @@ export interface CustomAgentConfig {
|
||||
claudeSkill?: string;
|
||||
}
|
||||
|
||||
/** Observability configuration for runtime event logs */
|
||||
export interface ObservabilityConfig {
|
||||
/** Logging configuration for runtime output */
|
||||
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) */
|
||||
providerEvents?: boolean;
|
||||
}
|
||||
@ -93,7 +99,7 @@ export interface PersistedGlobalConfig {
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||
model?: string;
|
||||
/** @globalOnly */
|
||||
observability?: ObservabilityConfig;
|
||||
logging?: LoggingConfig;
|
||||
analytics?: AnalyticsConfig;
|
||||
/** @globalOnly */
|
||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||
|
||||
@ -460,7 +460,10 @@ export const CustomAgentConfigSchema = z.object({
|
||||
{ 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(),
|
||||
});
|
||||
|
||||
@ -500,7 +503,7 @@ export const GlobalConfigSchema = z.object({
|
||||
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
||||
provider: ProviderReferenceSchema.optional().default('claude'),
|
||||
model: z.string().optional(),
|
||||
observability: ObservabilityConfigSchema.optional(),
|
||||
logging: LoggingConfigSchema.optional(),
|
||||
analytics: AnalyticsConfigSchema.optional(),
|
||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||
worktree_dir: z.string().optional(),
|
||||
|
||||
@ -63,7 +63,7 @@ export type {
|
||||
export type {
|
||||
PersonaProviderEntry,
|
||||
CustomAgentConfig,
|
||||
ObservabilityConfig,
|
||||
LoggingConfig,
|
||||
Language,
|
||||
PipelineConfig,
|
||||
ProjectConfig,
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Piece execution logic
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PieceEngine, createDenyAskUserQuestionHandler } from '../../../core/piece/index.js';
|
||||
@ -28,33 +24,12 @@ import { AnalyticsEmitter } from './analyticsEmitter.js';
|
||||
import { createOutputFns, createPrefixedStreamHandler } from './outputFns.js';
|
||||
import { RunMetaManager } from './runMeta.js';
|
||||
import { createIterationLimitHandler, createUserInputHandler } from './iterationLimitHandler.js';
|
||||
import { assertTaskPrefixPair, truncate, formatElapsedTime } from './pieceExecutionUtils.js';
|
||||
|
||||
export type { PieceExecutionResult, PieceExecutionOptions };
|
||||
|
||||
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(
|
||||
pieceConfig: PieceConfig,
|
||||
task: string,
|
||||
@ -100,7 +75,7 @@ export async function executePiece(
|
||||
const isWorktree = cwd !== projectCwd;
|
||||
const globalConfig = resolvePieceConfigValues(
|
||||
projectCwd,
|
||||
['notificationSound', 'notificationSoundEvents', 'provider', 'runtime', 'preventSleep', 'model', 'observability', 'analytics'],
|
||||
['notificationSound', 'notificationSoundEvents', 'provider', 'runtime', 'preventSleep', 'model', 'logging', 'analytics'],
|
||||
);
|
||||
const shouldNotify = globalConfig.notificationSound !== false;
|
||||
const nse = globalConfig.notificationSoundEvents;
|
||||
|
||||
20
src/features/tasks/execute/pieceExecutionUtils.ts
Normal file
20
src/features/tasks/execute/pieceExecutionUtils.ts
Normal 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`;
|
||||
}
|
||||
31
src/infra/config/env/config-env-overrides.ts
vendored
31
src/infra/config/env/config-env-overrides.ts
vendored
@ -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[] = [
|
||||
{ path: 'language', type: 'string' },
|
||||
{ path: 'provider', type: 'string' },
|
||||
{ path: 'model', type: 'string' },
|
||||
{ path: 'observability', type: 'json' },
|
||||
{ path: 'observability.provider_events', type: 'boolean' },
|
||||
{ path: 'logging', type: 'json' },
|
||||
{ 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.enabled', type: 'boolean' },
|
||||
{ path: 'analytics.events_path', type: 'string' },
|
||||
@ -155,6 +181,7 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
|
||||
|
||||
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {
|
||||
applyEnvOverrides(target, GLOBAL_ENV_SPECS);
|
||||
applyLegacyGlobalLoggingEnvOverrides(target);
|
||||
}
|
||||
|
||||
export function applyProjectConfigEnvOverrides(target: Record<string, unknown>): void {
|
||||
|
||||
@ -8,11 +8,7 @@ import {
|
||||
} from '../providerReference.js';
|
||||
import {
|
||||
normalizeProviderProfiles,
|
||||
denormalizeProviderProfiles,
|
||||
normalizePieceOverrides,
|
||||
denormalizePieceOverrides,
|
||||
denormalizeProviderOptions,
|
||||
normalizeRuntime,
|
||||
} from '../configNormalizers.js';
|
||||
import { getGlobalConfigPath } from '../paths.js';
|
||||
import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js';
|
||||
@ -23,7 +19,20 @@ import {
|
||||
removeMigratedProjectLocalKeys,
|
||||
type GlobalMigratedProjectLocalFallback,
|
||||
} from './globalMigratedProjectLocalFallback.js';
|
||||
import {
|
||||
sanitizeConfigValue,
|
||||
migrateDeprecatedGlobalConfigKeys,
|
||||
} from './globalConfigLegacyMigration.js';
|
||||
import { serializeGlobalConfig } from './globalConfigSerializer.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 RawProviderReference = ConfigProviderReference<ProviderType>;
|
||||
export class GlobalConfigManager {
|
||||
@ -59,15 +68,25 @@ export class GlobalConfigManager {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
const parsedRaw = parseYaml(content);
|
||||
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) {
|
||||
throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.');
|
||||
}
|
||||
}
|
||||
|
||||
applyGlobalConfigEnvOverrides(rawConfig);
|
||||
const migratedProjectLocalFallback = extractMigratedProjectLocalFallback(rawConfig);
|
||||
const schemaInput = { ...rawConfig };
|
||||
const { migratedConfig, migratedLogLevel } = migrateDeprecatedGlobalConfigKeys(rawConfig);
|
||||
const migratedProjectLocalFallback = extractMigratedProjectLocalFallback({
|
||||
...migratedConfig,
|
||||
...(migratedLogLevel !== undefined ? { log_level: migratedLogLevel } : {}),
|
||||
});
|
||||
const schemaInput = { ...migratedConfig };
|
||||
removeMigratedProjectLocalKeys(schemaInput);
|
||||
|
||||
const parsed = GlobalConfigSchema.parse(schemaInput);
|
||||
@ -80,8 +99,11 @@ export class GlobalConfigManager {
|
||||
language: parsed.language,
|
||||
provider: normalizedProvider.provider,
|
||||
model: normalizedProvider.model,
|
||||
observability: parsed.observability ? {
|
||||
providerEvents: parsed.observability.provider_events,
|
||||
logging: parsed.logging ? {
|
||||
level: parsed.logging.level,
|
||||
trace: parsed.logging.trace,
|
||||
debug: parsed.logging.debug,
|
||||
providerEvents: parsed.logging.provider_events,
|
||||
} : undefined,
|
||||
analytics: parsed.analytics ? {
|
||||
enabled: parsed.analytics.enabled,
|
||||
@ -110,7 +132,9 @@ export class GlobalConfigManager {
|
||||
pieceCategoriesFile: parsed.piece_categories_file,
|
||||
providerOptions: normalizedProvider.providerOptions,
|
||||
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,
|
||||
notificationSound: parsed.notification_sound,
|
||||
notificationSoundEvents: parsed.notification_sound_events ? {
|
||||
@ -140,136 +164,7 @@ export class GlobalConfigManager {
|
||||
|
||||
save(config: PersistedGlobalConfig): void {
|
||||
const configPath = getGlobalConfigPath();
|
||||
const raw: Record<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
const raw = serializeGlobalConfig(config);
|
||||
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
||||
this.invalidateCache();
|
||||
invalidateAllResolvedConfigCache();
|
||||
|
||||
99
src/infra/config/global/globalConfigLegacyMigration.ts
Normal file
99
src/infra/config/global/globalConfigLegacyMigration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
149
src/infra/config/global/globalConfigSerializer.ts
Normal file
149
src/infra/config/global/globalConfigSerializer.ts
Normal 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;
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { resolveConfigValue } from '../resolveConfigValue.js';
|
||||
import { isVerboseShortcutEnabled } from '../resolveConfigValue.js';
|
||||
|
||||
export function isVerboseMode(projectDir: string): boolean {
|
||||
return resolveConfigValue(projectDir, 'verbose');
|
||||
return isVerboseShortcutEnabled(projectDir);
|
||||
}
|
||||
|
||||
@ -75,6 +75,7 @@ const MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET = new Set(
|
||||
);
|
||||
|
||||
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
|
||||
logLevel: { layers: ['local', 'global'] },
|
||||
provider: {
|
||||
layers: ['local', 'piece', 'global'],
|
||||
pieceValue: (pieceContext) => pieceContext?.provider,
|
||||
@ -134,6 +135,10 @@ function getGlobalLayerValue<K extends ConfigParameterKey>(
|
||||
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
|
||||
key: K,
|
||||
): LoadedConfig[K] | undefined {
|
||||
if (key === 'logLevel' && global.logging?.level !== undefined) {
|
||||
return global.logging.level as LoadedConfig[K];
|
||||
}
|
||||
|
||||
if (isMigratedProjectLocalConfigKey(key)) {
|
||||
return globalMigratedProjectLocalFallback[key] as LoadedConfig[K] | undefined;
|
||||
}
|
||||
@ -243,3 +248,20 @@ export function resolveConfigValues<K extends ConfigParameterKey>(
|
||||
}
|
||||
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';
|
||||
}
|
||||
|
||||
@ -94,13 +94,19 @@ export function createProviderEventLogger(config: ProviderEventLoggerConfig): Pr
|
||||
const filepath = join(config.logsDir, `${config.sessionId}-provider-events.jsonl`);
|
||||
let movement = config.movement;
|
||||
let provider = config.provider;
|
||||
let hasReportedWriteFailure = false;
|
||||
|
||||
const write = (event: StreamEvent): void => {
|
||||
try {
|
||||
const record = buildLogRecord(event, provider, movement, config.runId);
|
||||
appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - observability logging should not interrupt main flow.
|
||||
} catch (error) {
|
||||
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?: {
|
||||
observability?: {
|
||||
logging?: {
|
||||
providerEvents?: boolean;
|
||||
};
|
||||
}): boolean {
|
||||
return config?.observability?.providerEvents === true;
|
||||
return config?.logging?.providerEvents === true;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user