opencode-dux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/agents/descriptions.d.ts +6 -0
- package/dist/agents/designer.d.ts +2 -0
- package/dist/agents/explorer.d.ts +2 -0
- package/dist/agents/fixer.d.ts +2 -0
- package/dist/agents/index.d.ts +22 -0
- package/dist/agents/interpreter.d.ts +2 -0
- package/dist/agents/librarian.d.ts +2 -0
- package/dist/agents/oracle.d.ts +2 -0
- package/dist/agents/orchestrator.d.ts +27 -0
- package/dist/agents/overrides.d.ts +18 -0
- package/dist/agents/prompt-blocks.d.ts +97 -0
- package/dist/agents/steward.d.ts +3 -0
- package/dist/cli/config-io.d.ts +24 -0
- package/dist/cli/config-manager.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1006 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/mcps.d.ts +13 -0
- package/dist/cli/model-key-normalization.d.ts +1 -0
- package/dist/cli/paths.d.ts +35 -0
- package/dist/cli/providers.d.ts +137 -0
- package/dist/cli/skills.d.ts +22 -0
- package/dist/cli/system.d.ts +5 -0
- package/dist/cli/types.d.ts +38 -0
- package/dist/config/constants.d.ts +12 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/loader.d.ts +40 -0
- package/dist/config/runtime-preset.d.ts +12 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/utils.d.ts +10 -0
- package/dist/discovery/local/types.d.ts +79 -0
- package/dist/discovery/local.d.ts +73 -0
- package/dist/discovery/mcp-servers.d.ts +88 -0
- package/dist/discovery/skills.d.ts +94 -0
- package/dist/hooks/apply-patch/codec.d.ts +7 -0
- package/dist/hooks/apply-patch/errors.d.ts +25 -0
- package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
- package/dist/hooks/apply-patch/index.d.ts +15 -0
- package/dist/hooks/apply-patch/matching.d.ts +26 -0
- package/dist/hooks/apply-patch/operations.d.ts +3 -0
- package/dist/hooks/apply-patch/patch.d.ts +2 -0
- package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
- package/dist/hooks/apply-patch/resolution.d.ts +19 -0
- package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
- package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
- package/dist/hooks/apply-patch/types.d.ts +80 -0
- package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
- package/dist/hooks/auto-update-checker/index.d.ts +18 -0
- package/dist/hooks/auto-update-checker/types.d.ts +22 -0
- package/dist/hooks/chat-headers.d.ts +16 -0
- package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
- package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
- package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
- package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
- package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
- package/dist/hooks/filter-available-skills/index.d.ts +32 -0
- package/dist/hooks/foreground-fallback/index.d.ts +72 -0
- package/dist/hooks/image-hook.d.ts +5 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
- package/dist/hooks/json-error-recovery/index.d.ts +1 -0
- package/dist/hooks/phase-reminder/index.d.ts +26 -0
- package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
- package/dist/hooks/task-session-manager/index.d.ts +52 -0
- package/dist/hooks/todo-continuation/index.d.ts +53 -0
- package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +31782 -0
- package/dist/mcp/context7.d.ts +6 -0
- package/dist/mcp/grep-app.d.ts +6 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/websearch.d.ts +9 -0
- package/dist/skills/registry.d.ts +29 -0
- package/dist/subscriptions/accounts-store.d.ts +57 -0
- package/dist/subscriptions/index.d.ts +13 -0
- package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
- package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
- package/dist/subscriptions/types.d.ts +115 -0
- package/dist/subscriptions/usage-service.d.ts +74 -0
- package/dist/tools/ast-grep/cli.d.ts +15 -0
- package/dist/tools/ast-grep/constants.d.ts +25 -0
- package/dist/tools/ast-grep/downloader.d.ts +5 -0
- package/dist/tools/ast-grep/index.d.ts +10 -0
- package/dist/tools/ast-grep/tools.d.ts +3 -0
- package/dist/tools/ast-grep/types.d.ts +30 -0
- package/dist/tools/ast-grep/utils.d.ts +4 -0
- package/dist/tools/delegate.d.ts +14 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/preset-manager.d.ts +27 -0
- package/dist/tools/smartfetch/binary.d.ts +3 -0
- package/dist/tools/smartfetch/cache.d.ts +6 -0
- package/dist/tools/smartfetch/constants.d.ts +12 -0
- package/dist/tools/smartfetch/index.d.ts +3 -0
- package/dist/tools/smartfetch/network.d.ts +38 -0
- package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
- package/dist/tools/smartfetch/tool.d.ts +3 -0
- package/dist/tools/smartfetch/types.d.ts +122 -0
- package/dist/tools/smartfetch/utils.d.ts +18 -0
- package/dist/tui-state.d.ts +168 -0
- package/dist/tui.d.ts +37 -0
- package/dist/tui.js +1896 -0
- package/dist/utils/agent-variant.d.ts +63 -0
- package/dist/utils/compat.d.ts +30 -0
- package/dist/utils/env.d.ts +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/internal-initiator.d.ts +6 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/polling.d.ts +21 -0
- package/dist/utils/session-manager.d.ts +55 -0
- package/dist/utils/session.d.ts +90 -0
- package/dist/utils/subagent-depth.d.ts +35 -0
- package/dist/utils/system-collapse.d.ts +6 -0
- package/dist/utils/task.d.ts +4 -0
- package/dist/utils/zip-extractor.d.ts +1 -0
- package/index.ts +1 -0
- package/opencode-dux.schema.json +634 -0
- package/package.json +103 -0
- package/src/agents/descriptions.ts +55 -0
- package/src/agents/designer.test.ts +86 -0
- package/src/agents/designer.ts +154 -0
- package/src/agents/display-name.test.ts +186 -0
- package/src/agents/explorer.test.ts +79 -0
- package/src/agents/explorer.ts +144 -0
- package/src/agents/fixer.test.ts +79 -0
- package/src/agents/fixer.ts +145 -0
- package/src/agents/index.test.ts +472 -0
- package/src/agents/index.ts +248 -0
- package/src/agents/interpreter.ts +136 -0
- package/src/agents/librarian.test.ts +80 -0
- package/src/agents/librarian.ts +145 -0
- package/src/agents/oracle.test.ts +89 -0
- package/src/agents/oracle.ts +184 -0
- package/src/agents/orchestrator.test.ts +116 -0
- package/src/agents/orchestrator.ts +574 -0
- package/src/agents/overrides.ts +95 -0
- package/src/agents/prompt-blocks.test.ts +114 -0
- package/src/agents/prompt-blocks.ts +640 -0
- package/src/agents/steward.ts +146 -0
- package/src/cli/config-io.test.ts +536 -0
- package/src/cli/config-io.ts +473 -0
- package/src/cli/config-manager.test.ts +141 -0
- package/src/cli/config-manager.ts +4 -0
- package/src/cli/index.ts +88 -0
- package/src/cli/install.ts +282 -0
- package/src/cli/mcps.test.ts +62 -0
- package/src/cli/mcps.ts +39 -0
- package/src/cli/model-key-normalization.test.ts +21 -0
- package/src/cli/model-key-normalization.ts +60 -0
- package/src/cli/paths.test.ts +167 -0
- package/src/cli/paths.ts +144 -0
- package/src/cli/providers.test.ts +118 -0
- package/src/cli/providers.ts +141 -0
- package/src/cli/skills.test.ts +111 -0
- package/src/cli/skills.ts +103 -0
- package/src/cli/system.test.ts +91 -0
- package/src/cli/system.ts +180 -0
- package/src/cli/types.ts +43 -0
- package/src/config/constants.ts +58 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.test.ts +1194 -0
- package/src/config/loader.ts +269 -0
- package/src/config/model-resolution.test.ts +176 -0
- package/src/config/runtime-preset.test.ts +61 -0
- package/src/config/runtime-preset.ts +37 -0
- package/src/config/schema.ts +248 -0
- package/src/config/utils.test.ts +41 -0
- package/src/config/utils.ts +23 -0
- package/src/discovery/local/types.ts +85 -0
- package/src/discovery/local.ts +322 -0
- package/src/discovery/mcp-servers.ts +804 -0
- package/src/discovery/skills.ts +959 -0
- package/src/hooks/apply-patch/codec.test.ts +184 -0
- package/src/hooks/apply-patch/codec.ts +352 -0
- package/src/hooks/apply-patch/errors.ts +117 -0
- package/src/hooks/apply-patch/execution-context.ts +432 -0
- package/src/hooks/apply-patch/hook.test.ts +768 -0
- package/src/hooks/apply-patch/index.ts +126 -0
- package/src/hooks/apply-patch/matching.test.ts +215 -0
- package/src/hooks/apply-patch/matching.ts +586 -0
- package/src/hooks/apply-patch/operations.test.ts +1535 -0
- package/src/hooks/apply-patch/operations.ts +3 -0
- package/src/hooks/apply-patch/patch.ts +9 -0
- package/src/hooks/apply-patch/prepared-changes.ts +400 -0
- package/src/hooks/apply-patch/resolution.test.ts +420 -0
- package/src/hooks/apply-patch/resolution.ts +437 -0
- package/src/hooks/apply-patch/rewrite.ts +496 -0
- package/src/hooks/apply-patch/test-helpers.ts +52 -0
- package/src/hooks/apply-patch/types.ts +111 -0
- package/src/hooks/auto-update-checker/cache.test.ts +179 -0
- package/src/hooks/auto-update-checker/cache.ts +188 -0
- package/src/hooks/auto-update-checker/checker.test.ts +159 -0
- package/src/hooks/auto-update-checker/checker.ts +308 -0
- package/src/hooks/auto-update-checker/constants.ts +33 -0
- package/src/hooks/auto-update-checker/index.test.ts +282 -0
- package/src/hooks/auto-update-checker/index.ts +225 -0
- package/src/hooks/auto-update-checker/types.ts +26 -0
- package/src/hooks/chat-headers.test.ts +236 -0
- package/src/hooks/chat-headers.ts +97 -0
- package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
- package/src/hooks/context-pressure-reminder/index.ts +137 -0
- package/src/hooks/delegate-task-retry/guidance.ts +41 -0
- package/src/hooks/delegate-task-retry/hook.ts +23 -0
- package/src/hooks/delegate-task-retry/index.test.ts +38 -0
- package/src/hooks/delegate-task-retry/index.ts +7 -0
- package/src/hooks/delegate-task-retry/patterns.ts +79 -0
- package/src/hooks/filter-available-skills/index.test.ts +297 -0
- package/src/hooks/filter-available-skills/index.ts +160 -0
- package/src/hooks/foreground-fallback/index.test.ts +624 -0
- package/src/hooks/foreground-fallback/index.ts +374 -0
- package/src/hooks/image-hook.ts +6 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/json-error-recovery/hook.ts +73 -0
- package/src/hooks/json-error-recovery/index.test.ts +111 -0
- package/src/hooks/json-error-recovery/index.ts +6 -0
- package/src/hooks/phase-reminder/index.test.ts +74 -0
- package/src/hooks/phase-reminder/index.ts +85 -0
- package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
- package/src/hooks/post-file-tool-nudge/index.ts +63 -0
- package/src/hooks/task-session-manager/index.test.ts +833 -0
- package/src/hooks/task-session-manager/index.ts +434 -0
- package/src/hooks/todo-continuation/index.test.ts +3026 -0
- package/src/hooks/todo-continuation/index.ts +878 -0
- package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
- package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
- package/src/index.ts +1672 -0
- package/src/mcp/context7.ts +14 -0
- package/src/mcp/grep-app.ts +11 -0
- package/src/mcp/index.test.ts +96 -0
- package/src/mcp/index.ts +66 -0
- package/src/mcp/types.ts +16 -0
- package/src/mcp/websearch.ts +47 -0
- package/src/skills/codemap/README.md +60 -0
- package/src/skills/codemap/SKILL.md +174 -0
- package/src/skills/codemap/scripts/codemap.mjs +483 -0
- package/src/skills/codemap/scripts/codemap.test.ts +129 -0
- package/src/skills/registry.ts +218 -0
- package/src/skills/simplify/README.md +19 -0
- package/src/skills/simplify/SKILL.md +138 -0
- package/src/subscriptions/accounts-store.test.ts +236 -0
- package/src/subscriptions/accounts-store.ts +184 -0
- package/src/subscriptions/index.ts +30 -0
- package/src/subscriptions/neuralwatt-scraper.ts +108 -0
- package/src/subscriptions/opencode-go-scraper.ts +301 -0
- package/src/subscriptions/types.ts +145 -0
- package/src/subscriptions/usage-service.test.ts +202 -0
- package/src/subscriptions/usage-service.ts +651 -0
- package/src/tools/ast-grep/cli.ts +257 -0
- package/src/tools/ast-grep/constants.ts +214 -0
- package/src/tools/ast-grep/downloader.ts +131 -0
- package/src/tools/ast-grep/index.ts +24 -0
- package/src/tools/ast-grep/tools.ts +117 -0
- package/src/tools/ast-grep/types.ts +51 -0
- package/src/tools/ast-grep/utils.ts +126 -0
- package/src/tools/delegate-handoff.test.ts +18 -0
- package/src/tools/delegate.ts +508 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/preset-manager.test.ts +795 -0
- package/src/tools/preset-manager.ts +332 -0
- package/src/tools/smartfetch/binary.ts +58 -0
- package/src/tools/smartfetch/cache.test.ts +34 -0
- package/src/tools/smartfetch/cache.ts +112 -0
- package/src/tools/smartfetch/constants.ts +29 -0
- package/src/tools/smartfetch/index.ts +8 -0
- package/src/tools/smartfetch/network.test.ts +178 -0
- package/src/tools/smartfetch/network.ts +614 -0
- package/src/tools/smartfetch/secondary-model.test.ts +85 -0
- package/src/tools/smartfetch/secondary-model.ts +276 -0
- package/src/tools/smartfetch/tool.test.ts +60 -0
- package/src/tools/smartfetch/tool.ts +832 -0
- package/src/tools/smartfetch/types.ts +135 -0
- package/src/tools/smartfetch/utils.test.ts +24 -0
- package/src/tools/smartfetch/utils.ts +456 -0
- package/src/tui-state.test.ts +867 -0
- package/src/tui-state.ts +1255 -0
- package/src/tui.test.ts +336 -0
- package/src/tui.ts +1539 -0
- package/src/utils/agent-variant.test.ts +244 -0
- package/src/utils/agent-variant.ts +187 -0
- package/src/utils/compat.ts +91 -0
- package/src/utils/env.ts +12 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/internal-initiator.ts +28 -0
- package/src/utils/logger.test.ts +220 -0
- package/src/utils/logger.ts +136 -0
- package/src/utils/polling.test.ts +191 -0
- package/src/utils/polling.ts +67 -0
- package/src/utils/session-manager.test.ts +173 -0
- package/src/utils/session-manager.ts +356 -0
- package/src/utils/session.test.ts +110 -0
- package/src/utils/session.ts +389 -0
- package/src/utils/subagent-depth.test.ts +170 -0
- package/src/utils/subagent-depth.ts +75 -0
- package/src/utils/system-collapse.test.ts +86 -0
- package/src/utils/system-collapse.ts +24 -0
- package/src/utils/task.test.ts +24 -0
- package/src/utils/task.ts +20 -0
- package/src/utils/zip-extractor.ts +102 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
flushLoggerForTesting,
|
|
7
|
+
getLogDir,
|
|
8
|
+
initLogger,
|
|
9
|
+
log,
|
|
10
|
+
resetLogger,
|
|
11
|
+
} from './logger';
|
|
12
|
+
|
|
13
|
+
describe('logger', () => {
|
|
14
|
+
let tmpDir: string;
|
|
15
|
+
let origLogDir: string | undefined;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logger-test-'));
|
|
19
|
+
origLogDir = process.env.OPENCODE_LOG_DIR;
|
|
20
|
+
process.env.OPENCODE_LOG_DIR = tmpDir;
|
|
21
|
+
resetLogger();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await flushLoggerForTesting();
|
|
26
|
+
if (origLogDir === undefined) {
|
|
27
|
+
delete process.env.OPENCODE_LOG_DIR;
|
|
28
|
+
} else {
|
|
29
|
+
process.env.OPENCODE_LOG_DIR = origLogDir;
|
|
30
|
+
}
|
|
31
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('log() silently no-ops before initLogger()', () => {
|
|
35
|
+
log('should not crash');
|
|
36
|
+
expect(fs.readdirSync(tmpDir).length).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('initLogger creates per-session log file', () => {
|
|
40
|
+
initLogger('20260416T143052');
|
|
41
|
+
log('test message');
|
|
42
|
+
|
|
43
|
+
const files = fs.readdirSync(tmpDir);
|
|
44
|
+
expect(files).toEqual(['opencode-dux.20260416T143052.log']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('writes log message with timestamp', async () => {
|
|
48
|
+
initLogger('session1');
|
|
49
|
+
log('timestamped message');
|
|
50
|
+
await flushLoggerForTesting();
|
|
51
|
+
|
|
52
|
+
const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
|
|
53
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
54
|
+
expect(content).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]/);
|
|
55
|
+
expect(content).toContain('timestamped message');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('logs message with data object', async () => {
|
|
59
|
+
initLogger('session1');
|
|
60
|
+
log('message with data', { key: 'value', number: 42 });
|
|
61
|
+
await flushLoggerForTesting();
|
|
62
|
+
|
|
63
|
+
const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
|
|
64
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
65
|
+
expect(content).toContain('"key":"value"');
|
|
66
|
+
expect(content).toContain('"number":42');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('logs message without extra JSON when no data', async () => {
|
|
70
|
+
initLogger('session1');
|
|
71
|
+
log('message without data');
|
|
72
|
+
await flushLoggerForTesting();
|
|
73
|
+
|
|
74
|
+
const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
|
|
75
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
76
|
+
expect(content.trim()).toMatch(/message without data\s*$/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('appends multiple log entries', async () => {
|
|
80
|
+
initLogger('session1');
|
|
81
|
+
log('first');
|
|
82
|
+
log('second');
|
|
83
|
+
log('third');
|
|
84
|
+
await flushLoggerForTesting();
|
|
85
|
+
|
|
86
|
+
const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
|
|
87
|
+
const lines = fs.readFileSync(logPath, 'utf-8').trim().split('\n');
|
|
88
|
+
expect(lines.length).toBe(3);
|
|
89
|
+
expect(lines[0]).toContain('first');
|
|
90
|
+
expect(lines[1]).toContain('second');
|
|
91
|
+
expect(lines[2]).toContain('third');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('initLogger called twice uses second session file', async () => {
|
|
95
|
+
initLogger('session1');
|
|
96
|
+
log('from session1');
|
|
97
|
+
initLogger('session2');
|
|
98
|
+
log('from session2');
|
|
99
|
+
await flushLoggerForTesting();
|
|
100
|
+
|
|
101
|
+
const files = fs.readdirSync(tmpDir).sort();
|
|
102
|
+
expect(files).toEqual([
|
|
103
|
+
'opencode-dux.session1.log',
|
|
104
|
+
'opencode-dux.session2.log',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const content1 = fs.readFileSync(path.join(tmpDir, files[0]), 'utf-8');
|
|
108
|
+
const content2 = fs.readFileSync(path.join(tmpDir, files[1]), 'utf-8');
|
|
109
|
+
expect(content1).toContain('from session1');
|
|
110
|
+
expect(content1).not.toContain('from session2');
|
|
111
|
+
expect(content2).toContain('from session2');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('cleanup keeps only the latest 10 log files', () => {
|
|
115
|
+
const baseTime = Date.now() - 50_000;
|
|
116
|
+
for (let i = 0; i < 10; i++) {
|
|
117
|
+
const fileName = `opencode-dux.seed-${i}.log`;
|
|
118
|
+
const filePath = path.join(tmpDir, fileName);
|
|
119
|
+
fs.writeFileSync(filePath, `seed-${i}\n`);
|
|
120
|
+
const mtime = baseTime + i * 1_000;
|
|
121
|
+
fs.utimesSync(filePath, new Date(mtime), new Date(mtime));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
initLogger('current');
|
|
125
|
+
log('init');
|
|
126
|
+
|
|
127
|
+
const files = fs
|
|
128
|
+
.readdirSync(tmpDir)
|
|
129
|
+
.filter(
|
|
130
|
+
(file) =>
|
|
131
|
+
file.startsWith('opencode-dux.') && file.endsWith('.log'),
|
|
132
|
+
)
|
|
133
|
+
.sort();
|
|
134
|
+
|
|
135
|
+
expect(files.length).toBe(10);
|
|
136
|
+
expect(files).toContain('opencode-dux.current.log');
|
|
137
|
+
expect(files).not.toContain('opencode-dux.seed-0.log');
|
|
138
|
+
expect(files).toContain('opencode-dux.seed-9.log');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('cleanup does not delete current session log when overflowing', () => {
|
|
142
|
+
const baseTime = Date.now() - 100_000;
|
|
143
|
+
for (let i = 0; i < 25; i++) {
|
|
144
|
+
const fileName = `opencode-dux.old-${i}.log`;
|
|
145
|
+
const filePath = path.join(tmpDir, fileName);
|
|
146
|
+
fs.writeFileSync(filePath, `old-${i}\n`);
|
|
147
|
+
const mtime = baseTime + i * 1_000;
|
|
148
|
+
fs.utimesSync(filePath, new Date(mtime), new Date(mtime));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
initLogger('current');
|
|
152
|
+
|
|
153
|
+
const files = fs
|
|
154
|
+
.readdirSync(tmpDir)
|
|
155
|
+
.filter(
|
|
156
|
+
(file) =>
|
|
157
|
+
file.startsWith('opencode-dux.') && file.endsWith('.log'),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(files.length).toBe(10);
|
|
161
|
+
expect(files).toContain('opencode-dux.current.log');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('cleanup with no existing files does not crash', () => {
|
|
165
|
+
expect(() => initLogger('fresh')).not.toThrow();
|
|
166
|
+
log('init');
|
|
167
|
+
const files = fs.readdirSync(tmpDir);
|
|
168
|
+
expect(files.find((f) => f.includes('fresh'))).toBeDefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('handles circular references in data', async () => {
|
|
172
|
+
initLogger('session1');
|
|
173
|
+
const circular: any = { name: 'test' };
|
|
174
|
+
circular.self = circular;
|
|
175
|
+
|
|
176
|
+
expect(() => log('circular data', circular)).not.toThrow();
|
|
177
|
+
await flushLoggerForTesting();
|
|
178
|
+
|
|
179
|
+
const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
|
|
180
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
181
|
+
expect(content).toContain('circular data');
|
|
182
|
+
expect(content).toContain('[unserializable]');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('getLogDir returns OPENCODE_LOG_DIR when set', () => {
|
|
186
|
+
expect(getLogDir()).toBe(tmpDir);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('getLogDir falls back to os.homedir when env not set', () => {
|
|
190
|
+
delete process.env.OPENCODE_LOG_DIR;
|
|
191
|
+
try {
|
|
192
|
+
expect(getLogDir()).toBe(
|
|
193
|
+
path.join(os.homedir(), '.local/share/opencode'),
|
|
194
|
+
);
|
|
195
|
+
} finally {
|
|
196
|
+
if (origLogDir === undefined) {
|
|
197
|
+
delete process.env.OPENCODE_LOG_DIR;
|
|
198
|
+
} else {
|
|
199
|
+
process.env.OPENCODE_LOG_DIR = origLogDir;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('handles complex data structures', async () => {
|
|
205
|
+
initLogger('session1');
|
|
206
|
+
log('complex data', {
|
|
207
|
+
nested: { deep: { value: 'test' } },
|
|
208
|
+
array: [1, 2, 3],
|
|
209
|
+
boolean: true,
|
|
210
|
+
null: null,
|
|
211
|
+
});
|
|
212
|
+
await flushLoggerForTesting();
|
|
213
|
+
|
|
214
|
+
const logPath = path.join(tmpDir, 'opencode-dux.session1.log');
|
|
215
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
216
|
+
expect(content).toContain('"nested":');
|
|
217
|
+
expect(content).toContain('"array":[1,2,3]');
|
|
218
|
+
expect(content).toContain('"boolean":true');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { appendFile } from 'node:fs/promises';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const LOG_PREFIX = 'opencode-dux.';
|
|
7
|
+
const LOG_SUFFIX = '.log';
|
|
8
|
+
const MAX_LOG_FILES = 10;
|
|
9
|
+
const MAX_BG_TASK_FILES = 10;
|
|
10
|
+
|
|
11
|
+
let logFile: string | null = null;
|
|
12
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
13
|
+
|
|
14
|
+
function getLogDir(): string {
|
|
15
|
+
return (
|
|
16
|
+
process.env.OPENCODE_LOG_DIR ??
|
|
17
|
+
path.join(os.homedir(), '.local/share/opencode')
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function trimByCount(
|
|
22
|
+
filePaths: string[],
|
|
23
|
+
maxFiles: number,
|
|
24
|
+
preservePath?: string,
|
|
25
|
+
): void {
|
|
26
|
+
if (filePaths.length <= maxFiles) return;
|
|
27
|
+
|
|
28
|
+
const sortedByMtime = filePaths
|
|
29
|
+
.map((filePath) => {
|
|
30
|
+
try {
|
|
31
|
+
return { filePath, mtimeMs: fs.statSync(filePath).mtimeMs };
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
.filter((entry): entry is { filePath: string; mtimeMs: number } =>
|
|
37
|
+
Boolean(entry),
|
|
38
|
+
)
|
|
39
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
40
|
+
|
|
41
|
+
const overflow = sortedByMtime.length - maxFiles;
|
|
42
|
+
if (overflow <= 0) return;
|
|
43
|
+
|
|
44
|
+
const candidates = preservePath
|
|
45
|
+
? sortedByMtime.filter((entry) => entry.filePath !== preservePath)
|
|
46
|
+
: sortedByMtime;
|
|
47
|
+
|
|
48
|
+
for (const entry of candidates.slice(0, overflow)) {
|
|
49
|
+
try {
|
|
50
|
+
fs.unlinkSync(entry.filePath);
|
|
51
|
+
} catch {
|
|
52
|
+
// Skip individual file errors
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cleanupOldLogs(logDir: string, preservePath?: string): void {
|
|
58
|
+
try {
|
|
59
|
+
const entries = fs.readdirSync(logDir);
|
|
60
|
+
const logFiles = entries
|
|
61
|
+
.filter(
|
|
62
|
+
(entry) => entry.startsWith(LOG_PREFIX) && entry.endsWith(LOG_SUFFIX),
|
|
63
|
+
)
|
|
64
|
+
.map((entry) => path.join(logDir, entry));
|
|
65
|
+
|
|
66
|
+
trimByCount(logFiles, MAX_LOG_FILES, preservePath);
|
|
67
|
+
} catch {
|
|
68
|
+
// Directory may not exist yet - that's fine
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Apply the same count-based retention to persisted background task files
|
|
72
|
+
try {
|
|
73
|
+
const bgTaskDir = path.join(logDir, 'bg-tasks');
|
|
74
|
+
const taskFiles = fs
|
|
75
|
+
.readdirSync(bgTaskDir)
|
|
76
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
77
|
+
.map((entry) => path.join(bgTaskDir, entry));
|
|
78
|
+
|
|
79
|
+
trimByCount(taskFiles, MAX_BG_TASK_FILES);
|
|
80
|
+
} catch {
|
|
81
|
+
// bg-tasks dir may not exist yet - that's fine
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function initLogger(sessionId: string): void {
|
|
86
|
+
const dir = getLogDir();
|
|
87
|
+
try {
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
89
|
+
} catch {
|
|
90
|
+
// Directory creation failed - logging will silently fail
|
|
91
|
+
}
|
|
92
|
+
logFile = path.join(dir, `${LOG_PREFIX}${sessionId}${LOG_SUFFIX}`);
|
|
93
|
+
try {
|
|
94
|
+
fs.closeSync(fs.openSync(logFile, 'a'));
|
|
95
|
+
} catch {
|
|
96
|
+
// File creation failed - later writes will silently fail
|
|
97
|
+
}
|
|
98
|
+
cleanupOldLogs(dir, logFile);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @internal Reset logger state for testing */
|
|
102
|
+
export function resetLogger(): void {
|
|
103
|
+
logFile = null;
|
|
104
|
+
writeChain = Promise.resolve();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @internal Wait for queued log writes in tests. */
|
|
108
|
+
export async function flushLoggerForTesting(): Promise<void> {
|
|
109
|
+
await writeChain;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export { getLogDir };
|
|
113
|
+
|
|
114
|
+
export function log(message: string, data?: unknown): void {
|
|
115
|
+
const target = logFile;
|
|
116
|
+
if (!target) return; // Uninitialized - silently no-op
|
|
117
|
+
try {
|
|
118
|
+
const timestamp = new Date().toISOString();
|
|
119
|
+
let dataStr = '';
|
|
120
|
+
if (data !== undefined) {
|
|
121
|
+
try {
|
|
122
|
+
dataStr = JSON.stringify(data);
|
|
123
|
+
} catch {
|
|
124
|
+
dataStr = '[unserializable]';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const logEntry = `[${timestamp}] ${message} ${dataStr}\n`;
|
|
128
|
+
writeChain = writeChain
|
|
129
|
+
.then(() => appendFile(target, logEntry))
|
|
130
|
+
.catch(() => {
|
|
131
|
+
// Silently ignore logging errors and keep future writes alive
|
|
132
|
+
});
|
|
133
|
+
} catch {
|
|
134
|
+
// Silently ignore logging errors
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { delay, pollUntilStable } from './polling';
|
|
3
|
+
|
|
4
|
+
describe('pollUntilStable', () => {
|
|
5
|
+
test('returns success when condition becomes stable', async () => {
|
|
6
|
+
let callCount = 0;
|
|
7
|
+
const fetchFn = async () => {
|
|
8
|
+
callCount++;
|
|
9
|
+
return callCount >= 3 ? 'stable' : 'changing';
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const isStable = (current: string, previous: string | null) => {
|
|
13
|
+
return current === 'stable' && previous === 'stable';
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const result = await pollUntilStable(fetchFn, isStable, {
|
|
17
|
+
pollInterval: 10,
|
|
18
|
+
maxPollTime: 1000,
|
|
19
|
+
stableThreshold: 2,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(result.success).toBe(true);
|
|
23
|
+
expect(result.data).toBe('stable');
|
|
24
|
+
expect(result.timedOut).toBeUndefined();
|
|
25
|
+
expect(result.aborted).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('returns timeout when max poll time exceeded', async () => {
|
|
29
|
+
const fetchFn = async () => 'always-changing';
|
|
30
|
+
const isStable = () => false; // Never stable
|
|
31
|
+
|
|
32
|
+
const result = await pollUntilStable(fetchFn, isStable, {
|
|
33
|
+
pollInterval: 10,
|
|
34
|
+
maxPollTime: 50, // Very short timeout
|
|
35
|
+
stableThreshold: 2,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result.success).toBe(false);
|
|
39
|
+
expect(result.timedOut).toBe(true);
|
|
40
|
+
expect(result.data).toBe('always-changing');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('returns aborted when signal is aborted', async () => {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const fetchFn = async () => {
|
|
46
|
+
// Abort after first call
|
|
47
|
+
controller.abort();
|
|
48
|
+
return 'data';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isStable = () => false;
|
|
52
|
+
|
|
53
|
+
const result = await pollUntilStable(fetchFn, isStable, {
|
|
54
|
+
pollInterval: 10,
|
|
55
|
+
maxPollTime: 1000,
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result.success).toBe(false);
|
|
60
|
+
expect(result.aborted).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('respects custom stability threshold', async () => {
|
|
64
|
+
let callCount = 0;
|
|
65
|
+
const fetchFn = async () => {
|
|
66
|
+
callCount++;
|
|
67
|
+
return callCount >= 2 ? 'stable' : 'changing';
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const isStable = (
|
|
71
|
+
current: string,
|
|
72
|
+
previous: string | null,
|
|
73
|
+
_stableCount: number,
|
|
74
|
+
) => {
|
|
75
|
+
return current === 'stable' && previous === 'stable';
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const result = await pollUntilStable(fetchFn, isStable, {
|
|
79
|
+
pollInterval: 10,
|
|
80
|
+
maxPollTime: 1000,
|
|
81
|
+
stableThreshold: 3, // Require 3 stable polls
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result.success).toBe(true);
|
|
85
|
+
expect(callCount).toBeGreaterThanOrEqual(5); // At least 2 changing + 3 stable
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('resets stable count when condition becomes unstable', async () => {
|
|
89
|
+
let callCount = 0;
|
|
90
|
+
const values = ['a', 'a', 'b', 'b', 'b', 'b']; // Unstable, then stable
|
|
91
|
+
const fetchFn = async () => values[callCount++] || 'b';
|
|
92
|
+
|
|
93
|
+
const isStable = (current: string, previous: string | null) => {
|
|
94
|
+
return current === previous && current === 'b';
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const result = await pollUntilStable(fetchFn, isStable, {
|
|
98
|
+
pollInterval: 10,
|
|
99
|
+
maxPollTime: 1000,
|
|
100
|
+
stableThreshold: 3,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.success).toBe(true);
|
|
104
|
+
expect(result.data).toBe('b');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('uses default options when not provided', async () => {
|
|
108
|
+
let callCount = 0;
|
|
109
|
+
const fetchFn = async () => {
|
|
110
|
+
callCount++;
|
|
111
|
+
return callCount >= 2 ? 'stable' : 'changing';
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const isStable = (current: string, previous: string | null) => {
|
|
115
|
+
return current === 'stable' && previous === 'stable';
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const result = await pollUntilStable(fetchFn, isStable);
|
|
119
|
+
|
|
120
|
+
expect(result.success).toBe(true);
|
|
121
|
+
expect(result.data).toBe('stable');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('handles fetchFn that throws errors', async () => {
|
|
125
|
+
const fetchFn = async () => {
|
|
126
|
+
throw new Error('Fetch failed');
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const isStable = () => false;
|
|
130
|
+
|
|
131
|
+
await expect(
|
|
132
|
+
pollUntilStable(fetchFn, isStable, {
|
|
133
|
+
pollInterval: 10,
|
|
134
|
+
maxPollTime: 100,
|
|
135
|
+
}),
|
|
136
|
+
).rejects.toThrow('Fetch failed');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('passes stable count to isStable function', async () => {
|
|
140
|
+
let _callCount = 0;
|
|
141
|
+
const fetchFn = async () => {
|
|
142
|
+
_callCount++;
|
|
143
|
+
return 'data';
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let maxStableCount = 0;
|
|
147
|
+
const isStable = (
|
|
148
|
+
current: string,
|
|
149
|
+
previous: string | null,
|
|
150
|
+
stableCount: number,
|
|
151
|
+
) => {
|
|
152
|
+
maxStableCount = Math.max(maxStableCount, stableCount);
|
|
153
|
+
// Check if data is actually stable (same as previous)
|
|
154
|
+
return current === previous && current === 'data';
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const result = await pollUntilStable(fetchFn, isStable, {
|
|
158
|
+
pollInterval: 10,
|
|
159
|
+
maxPollTime: 1000,
|
|
160
|
+
stableThreshold: 3,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(result.success).toBe(true);
|
|
164
|
+
expect(maxStableCount).toBeGreaterThanOrEqual(2);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('delay', () => {
|
|
169
|
+
test('delays for specified milliseconds', async () => {
|
|
170
|
+
const start = Date.now();
|
|
171
|
+
await delay(50);
|
|
172
|
+
const elapsed = Date.now() - start;
|
|
173
|
+
|
|
174
|
+
// Allow some tolerance for timing
|
|
175
|
+
expect(elapsed).toBeGreaterThanOrEqual(45);
|
|
176
|
+
expect(elapsed).toBeLessThan(100);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('resolves without value', async () => {
|
|
180
|
+
const result = await delay(10);
|
|
181
|
+
expect(result).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('can be used in promise chains', async () => {
|
|
185
|
+
const result = await Promise.resolve('test')
|
|
186
|
+
.then((val) => delay(10).then(() => val))
|
|
187
|
+
.then((val) => val.toUpperCase());
|
|
188
|
+
|
|
189
|
+
expect(result).toBe('TEST');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_POLL_TIME_MS,
|
|
3
|
+
POLL_INTERVAL_MS,
|
|
4
|
+
STABLE_POLLS_THRESHOLD,
|
|
5
|
+
} from '../config';
|
|
6
|
+
|
|
7
|
+
export interface PollOptions {
|
|
8
|
+
pollInterval?: number;
|
|
9
|
+
maxPollTime?: number;
|
|
10
|
+
stableThreshold?: number;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PollResult<T> {
|
|
15
|
+
success: boolean;
|
|
16
|
+
data?: T;
|
|
17
|
+
timedOut?: boolean;
|
|
18
|
+
aborted?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generic polling utility that waits for a condition to be met.
|
|
23
|
+
* Returns when the condition is satisfied or timeout/abort occurs.
|
|
24
|
+
*/
|
|
25
|
+
export async function pollUntilStable<T>(
|
|
26
|
+
fetchFn: () => Promise<T>,
|
|
27
|
+
isStable: (current: T, previous: T | null, stableCount: number) => boolean,
|
|
28
|
+
opts: PollOptions = {},
|
|
29
|
+
): Promise<PollResult<T>> {
|
|
30
|
+
const pollInterval = opts.pollInterval ?? POLL_INTERVAL_MS;
|
|
31
|
+
const maxPollTime = opts.maxPollTime ?? MAX_POLL_TIME_MS;
|
|
32
|
+
const stableThreshold = opts.stableThreshold ?? STABLE_POLLS_THRESHOLD;
|
|
33
|
+
|
|
34
|
+
const pollStart = Date.now();
|
|
35
|
+
let previousData: T | null = null;
|
|
36
|
+
let stablePolls = 0;
|
|
37
|
+
|
|
38
|
+
while (Date.now() - pollStart < maxPollTime) {
|
|
39
|
+
if (opts.signal?.aborted) {
|
|
40
|
+
return { success: false, aborted: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
44
|
+
|
|
45
|
+
const currentData = await fetchFn();
|
|
46
|
+
|
|
47
|
+
if (isStable(currentData, previousData, stablePolls)) {
|
|
48
|
+
stablePolls++;
|
|
49
|
+
if (stablePolls >= stableThreshold) {
|
|
50
|
+
return { success: true, data: currentData };
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
stablePolls = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
previousData = currentData;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { success: false, timedOut: true, data: previousData ?? undefined };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Simple delay utility
|
|
64
|
+
*/
|
|
65
|
+
export function delay(ms: number): Promise<void> {
|
|
66
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
67
|
+
}
|