discoclaw 1.2.4 → 2.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/.context/voice.md +30 -2
- package/.env.example +7 -3
- package/.env.example.full +13 -32
- package/README.md +1 -1
- package/dist/cli/dashboard.js +7 -1
- package/dist/cli/dashboard.test.js +0 -4
- package/dist/cli/init-wizard.js +4 -8
- package/dist/cli/init-wizard.test.js +4 -10
- package/dist/config.js +5 -38
- package/dist/config.test.js +8 -72
- package/dist/cron/executor.js +72 -1
- package/dist/dashboard/api/metrics.js +7 -0
- package/dist/dashboard/api/metrics.test.js +16 -0
- package/dist/dashboard/api/traces.js +14 -0
- package/dist/dashboard/api/traces.test.js +40 -0
- package/dist/dashboard/page.js +187 -8
- package/dist/dashboard/server.js +82 -19
- package/dist/dashboard/server.test.js +123 -10
- package/dist/discord/actions.js +112 -6
- package/dist/discord/actions.test.js +117 -1
- package/dist/discord/deferred-runner.js +306 -219
- package/dist/discord/help-command.js +1 -1
- package/dist/discord/message-coordinator.js +4 -36
- package/dist/discord/models-command.js +1 -1
- package/dist/discord/reaction-handler.js +83 -5
- package/dist/discord/reaction-handler.test.js +55 -0
- package/dist/discord/verify-push.js +31 -36
- package/dist/discord/verify-push.test.js +34 -6
- package/dist/discord/voice-command.js +1 -31
- package/dist/discord/voice-command.test.js +21 -259
- package/dist/discord/voice-status-command.js +3 -22
- package/dist/discord/voice-status-command.test.js +16 -124
- package/dist/discord-followup.test.js +133 -0
- package/dist/health/config-doctor.js +5 -27
- package/dist/health/config-doctor.test.js +1 -4
- package/dist/index.js +15 -28
- package/dist/observability/trace-store.js +56 -0
- package/dist/observability/trace-utils.js +31 -0
- package/dist/runtime/codex-cli.js +3 -2
- package/dist/runtime/codex-cli.test.js +33 -0
- package/dist/runtime/model-tiers.js +1 -1
- package/dist/runtime/model-tiers.test.js +9 -0
- package/dist/runtime/openai-tool-schemas.js +17 -0
- package/dist/runtime-overrides.js +2 -3
- package/dist/runtime-overrides.test.js +27 -193
- package/dist/tasks/store.js +10 -6
- package/dist/tasks/store.test.js +44 -0
- package/dist/tasks/task-action-executor.test.js +162 -50
- package/dist/tasks/task-action-mutations.js +22 -2
- package/dist/tasks/task-action-read-ops.js +7 -1
- package/dist/tasks/task-action-runner-types.js +19 -1
- package/dist/voice/audio-pipeline.js +183 -96
- package/dist/voice/audio-receiver.js +8 -0
- package/dist/voice/audio-receiver.test.js +16 -0
- package/dist/voice/conversation-buffer.js +16 -6
- package/dist/voice/providers/gemini-live-provider.js +481 -0
- package/dist/voice/providers/gemini-live-provider.test.js +834 -0
- package/dist/voice/providers/gemini-live-responder.js +267 -0
- package/dist/voice/providers/gemini-live-responder.test.js +615 -0
- package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
- package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
- package/dist/voice/providers/gemini-live-types.js +32 -0
- package/dist/voice/providers/gemini-tool-mapper.js +91 -0
- package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
- package/dist/voice/providers/index.js +3 -0
- package/dist/voice/voice-prompt-builder.js +26 -17
- package/dist/voice/voice-prompt-builder.test.js +16 -1
- package/docs/configuration.md +4 -9
- package/docs/official-docs.md +6 -9
- package/docs/runtime-switching.md +1 -1
- package/package.json +1 -1
- package/dist/voice/audio-pipeline.test.js +0 -619
- package/dist/voice/stt-deepgram.js +0 -154
- package/dist/voice/stt-deepgram.test.js +0 -275
- package/dist/voice/stt-factory.js +0 -42
- package/dist/voice/stt-factory.test.js +0 -45
- package/dist/voice/stt-openai.js +0 -156
- package/dist/voice/stt-openai.test.js +0 -281
- package/dist/voice/tts-cartesia.js +0 -169
- package/dist/voice/tts-cartesia.test.js +0 -228
- package/dist/voice/tts-deepgram.js +0 -84
- package/dist/voice/tts-deepgram.test.js +0 -220
- package/dist/voice/tts-factory.js +0 -52
- package/dist/voice/tts-factory.test.js +0 -53
- package/dist/voice/tts-openai.js +0 -70
- package/dist/voice/tts-openai.test.js +0 -138
- package/dist/voice/types.test.js +0 -84
|
@@ -1,229 +1,63 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
4
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { loadOverrides, normalizeRuntimeOverrides, resolveOverridesPath, saveOverrides
|
|
5
|
+
import { loadOverrides, normalizeRuntimeOverrides, resolveOverridesPath, saveOverrides } from './runtime-overrides.js';
|
|
6
6
|
async function tmpDir() {
|
|
7
7
|
return fs.mkdtemp(path.join(os.tmpdir(), 'runtime-overrides-'));
|
|
8
8
|
}
|
|
9
9
|
describe('resolveOverridesPath', () => {
|
|
10
10
|
it('uses configured data dir when provided', () => {
|
|
11
|
-
|
|
12
|
-
expect(out).toBe(path.join('/var/lib/discoclaw', 'runtime-overrides.json'));
|
|
13
|
-
});
|
|
14
|
-
it('falls back to <projectRoot>/data when data dir is empty string', () => {
|
|
15
|
-
const out = resolveOverridesPath('', '/repo');
|
|
16
|
-
expect(out).toBe(path.join('/repo', 'data', 'runtime-overrides.json'));
|
|
11
|
+
expect(resolveOverridesPath('/var/lib/discoclaw', '/repo')).toBe('/var/lib/discoclaw/runtime-overrides.json');
|
|
17
12
|
});
|
|
18
|
-
it('falls back to <projectRoot>/data when data dir is
|
|
19
|
-
|
|
20
|
-
expect(out).toBe(path.join('/repo', 'data', 'runtime-overrides.json'));
|
|
13
|
+
it('falls back to <projectRoot>/data when data dir is absent', () => {
|
|
14
|
+
expect(resolveOverridesPath(undefined, '/repo')).toBe('/repo/data/runtime-overrides.json');
|
|
21
15
|
});
|
|
22
16
|
});
|
|
23
|
-
describe('loadOverrides', () => {
|
|
17
|
+
describe('loadOverrides and saveOverrides', () => {
|
|
24
18
|
const dirs = [];
|
|
25
19
|
afterEach(async () => {
|
|
26
|
-
for (const
|
|
27
|
-
await fs.rm(
|
|
20
|
+
for (const dir of dirs) {
|
|
21
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
28
22
|
}
|
|
29
23
|
dirs.length = 0;
|
|
30
24
|
});
|
|
31
|
-
it('returns empty object when file does not exist', async () => {
|
|
32
|
-
const dir = await tmpDir();
|
|
33
|
-
dirs.push(dir);
|
|
34
|
-
const result = await loadOverrides(path.join(dir, 'runtime-overrides.json'));
|
|
35
|
-
expect(result).toEqual({});
|
|
36
|
-
});
|
|
37
|
-
it('loads ttsVoice from a valid file', async () => {
|
|
38
|
-
const dir = await tmpDir();
|
|
39
|
-
dirs.push(dir);
|
|
40
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
41
|
-
await fs.writeFile(filePath, JSON.stringify({ ttsVoice: 'aura-2-luna-en' }), 'utf-8');
|
|
42
|
-
const result = await loadOverrides(filePath);
|
|
43
|
-
expect(result).toEqual({ ttsVoice: 'aura-2-luna-en' });
|
|
44
|
-
});
|
|
45
|
-
it('returns empty object for corrupt JSON and calls onWarn', async () => {
|
|
46
|
-
const dir = await tmpDir();
|
|
47
|
-
dirs.push(dir);
|
|
48
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
49
|
-
await fs.writeFile(filePath, 'not-valid-json', 'utf-8');
|
|
50
|
-
const warnings = [];
|
|
51
|
-
const result = await loadOverrides(filePath, (msg) => warnings.push(msg));
|
|
52
|
-
expect(result).toEqual({});
|
|
53
|
-
expect(warnings).toHaveLength(1);
|
|
54
|
-
expect(warnings[0]).toMatch(/corrupt JSON/);
|
|
55
|
-
});
|
|
56
|
-
it('returns empty object when JSON root is an array and calls onWarn', async () => {
|
|
57
|
-
const dir = await tmpDir();
|
|
58
|
-
dirs.push(dir);
|
|
59
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
60
|
-
await fs.writeFile(filePath, JSON.stringify(['opus']), 'utf-8');
|
|
61
|
-
const warnings = [];
|
|
62
|
-
const result = await loadOverrides(filePath, (msg) => warnings.push(msg));
|
|
63
|
-
expect(result).toEqual({});
|
|
64
|
-
expect(warnings).toHaveLength(1);
|
|
65
|
-
expect(warnings[0]).toMatch(/not an object/);
|
|
66
|
-
});
|
|
67
|
-
it('returns empty object when JSON root is a primitive and calls onWarn', async () => {
|
|
68
|
-
const dir = await tmpDir();
|
|
69
|
-
dirs.push(dir);
|
|
70
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
71
|
-
await fs.writeFile(filePath, JSON.stringify(42), 'utf-8');
|
|
72
|
-
const warnings = [];
|
|
73
|
-
const result = await loadOverrides(filePath, (msg) => warnings.push(msg));
|
|
74
|
-
expect(result).toEqual({});
|
|
75
|
-
expect(warnings).toHaveLength(1);
|
|
76
|
-
expect(warnings[0]).toMatch(/not an object/);
|
|
77
|
-
});
|
|
78
|
-
it('silently drops unknown top-level fields (including legacy models key)', async () => {
|
|
79
|
-
const dir = await tmpDir();
|
|
80
|
-
dirs.push(dir);
|
|
81
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
82
|
-
await fs.writeFile(filePath, JSON.stringify({ models: { chat: 'sonnet' }, unknownField: 'x' }), 'utf-8');
|
|
83
|
-
const result = await loadOverrides(filePath);
|
|
84
|
-
expect(result).toEqual({});
|
|
85
|
-
});
|
|
86
|
-
it('loads ttsVoice field correctly', async () => {
|
|
25
|
+
it('returns empty object when the file does not exist', async () => {
|
|
87
26
|
const dir = await tmpDir();
|
|
88
27
|
dirs.push(dir);
|
|
89
|
-
|
|
90
|
-
await fs.writeFile(filePath, JSON.stringify({ ttsVoice: 'aura-2-asteria-en' }), 'utf-8');
|
|
91
|
-
const result = await loadOverrides(filePath);
|
|
92
|
-
expect(result).toEqual({ ttsVoice: 'aura-2-asteria-en' });
|
|
28
|
+
await expect(loadOverrides(path.join(dir, 'runtime-overrides.json'))).resolves.toEqual({});
|
|
93
29
|
});
|
|
94
|
-
it('
|
|
30
|
+
it('round-trips managed runtime overrides', async () => {
|
|
95
31
|
const dir = await tmpDir();
|
|
96
32
|
dirs.push(dir);
|
|
97
33
|
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
98
|
-
await
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const dir = await tmpDir();
|
|
104
|
-
dirs.push(dir);
|
|
105
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
106
|
-
await fs.writeFile(filePath, JSON.stringify({ voiceRuntime: 'gemini-api' }), 'utf-8');
|
|
107
|
-
const result = await loadOverrides(filePath);
|
|
108
|
-
expect(result).toEqual({ voiceRuntime: 'gemini-api' });
|
|
34
|
+
await saveOverrides(filePath, { voiceRuntime: 'claude-api', fastRuntime: 'codex-cli' });
|
|
35
|
+
await expect(loadOverrides(filePath)).resolves.toEqual({
|
|
36
|
+
voiceRuntime: 'claude-api',
|
|
37
|
+
fastRuntime: 'codex-cli',
|
|
38
|
+
});
|
|
109
39
|
});
|
|
110
|
-
it('
|
|
40
|
+
it('drops legacy ttsVoice while preserving unrelated keys', async () => {
|
|
111
41
|
const dir = await tmpDir();
|
|
112
42
|
dirs.push(dir);
|
|
113
43
|
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
114
|
-
await fs.writeFile(filePath, JSON.stringify({ voiceRuntime:
|
|
115
|
-
|
|
116
|
-
|
|
44
|
+
await fs.writeFile(filePath, JSON.stringify({ customFlag: true, ttsVoice: 'old-voice', voiceRuntime: 'gemini-api' }), 'utf-8');
|
|
45
|
+
await saveOverrides(filePath, { fastRuntime: 'codex-cli' });
|
|
46
|
+
const raw = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
47
|
+
expect(raw).toEqual({ customFlag: true, fastRuntime: 'codex-cli' });
|
|
117
48
|
});
|
|
118
49
|
});
|
|
119
50
|
describe('normalizeRuntimeOverrides', () => {
|
|
120
|
-
it('canonicalizes accepted
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
overrides: { voiceRuntime: 'claude-api' },
|
|
51
|
+
it('canonicalizes accepted runtime aliases', () => {
|
|
52
|
+
expect(normalizeRuntimeOverrides({ voiceRuntime: 'Anthropic', fastRuntime: 'claude_code' })).toEqual({
|
|
53
|
+
overrides: { voiceRuntime: 'claude-api', fastRuntime: 'claude-cli' },
|
|
124
54
|
changed: true,
|
|
125
55
|
});
|
|
126
56
|
});
|
|
127
|
-
it('
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
overrides: { fastRuntime: 'claude-cli' },
|
|
131
|
-
changed: true,
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
it('leaves invalid or already-canonical runtime values unchanged', () => {
|
|
135
|
-
const result = normalizeRuntimeOverrides({
|
|
136
|
-
voiceRuntime: 'claude-api',
|
|
137
|
-
fastRuntime: 'not-a-runtime',
|
|
138
|
-
ttsVoice: 'aura-2-asteria-en',
|
|
139
|
-
});
|
|
140
|
-
expect(result).toEqual({
|
|
141
|
-
overrides: {
|
|
142
|
-
voiceRuntime: 'claude-api',
|
|
143
|
-
fastRuntime: 'not-a-runtime',
|
|
144
|
-
ttsVoice: 'aura-2-asteria-en',
|
|
145
|
-
},
|
|
57
|
+
it('leaves invalid or canonical runtime values unchanged', () => {
|
|
58
|
+
expect(normalizeRuntimeOverrides({ voiceRuntime: 'claude-api', fastRuntime: 'not-a-runtime' })).toEqual({
|
|
59
|
+
overrides: { voiceRuntime: 'claude-api', fastRuntime: 'not-a-runtime' },
|
|
146
60
|
changed: false,
|
|
147
61
|
});
|
|
148
62
|
});
|
|
149
63
|
});
|
|
150
|
-
describe('saveOverrides', () => {
|
|
151
|
-
const dirs = [];
|
|
152
|
-
afterEach(async () => {
|
|
153
|
-
for (const d of dirs) {
|
|
154
|
-
await fs.rm(d, { recursive: true, force: true });
|
|
155
|
-
}
|
|
156
|
-
dirs.length = 0;
|
|
157
|
-
});
|
|
158
|
-
it('writes overrides and reads them back correctly', async () => {
|
|
159
|
-
const dir = await tmpDir();
|
|
160
|
-
dirs.push(dir);
|
|
161
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
162
|
-
await saveOverrides(filePath, { ttsVoice: 'aura-2-asteria-en', voiceRuntime: 'claude-api' });
|
|
163
|
-
const raw = await fs.readFile(filePath, 'utf-8');
|
|
164
|
-
expect(JSON.parse(raw)).toEqual({ ttsVoice: 'aura-2-asteria-en', voiceRuntime: 'claude-api' });
|
|
165
|
-
});
|
|
166
|
-
it('writes ttsVoice and reads it back correctly', async () => {
|
|
167
|
-
const dir = await tmpDir();
|
|
168
|
-
dirs.push(dir);
|
|
169
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
170
|
-
await saveOverrides(filePath, { ttsVoice: 'aura-2-asteria-en' });
|
|
171
|
-
const result = await loadOverrides(filePath);
|
|
172
|
-
expect(result).toEqual({ ttsVoice: 'aura-2-asteria-en' });
|
|
173
|
-
});
|
|
174
|
-
it('round-trips ttsVoice + voiceRuntime through save and load', async () => {
|
|
175
|
-
const dir = await tmpDir();
|
|
176
|
-
dirs.push(dir);
|
|
177
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
178
|
-
const original = {
|
|
179
|
-
ttsVoice: 'aura-2-luna-en',
|
|
180
|
-
voiceRuntime: 'gemini-api',
|
|
181
|
-
};
|
|
182
|
-
await saveOverrides(filePath, original);
|
|
183
|
-
const result = await loadOverrides(filePath);
|
|
184
|
-
expect(result).toEqual(original);
|
|
185
|
-
});
|
|
186
|
-
it('creates the parent directory when it does not exist', async () => {
|
|
187
|
-
const dir = await tmpDir();
|
|
188
|
-
dirs.push(dir);
|
|
189
|
-
const filePath = path.join(dir, 'subdir', 'runtime-overrides.json');
|
|
190
|
-
await saveOverrides(filePath, { voiceRuntime: 'claude-api' });
|
|
191
|
-
const raw = await fs.readFile(filePath, 'utf-8');
|
|
192
|
-
expect(JSON.parse(raw)).toEqual({ voiceRuntime: 'claude-api' });
|
|
193
|
-
});
|
|
194
|
-
it('leaves no tmp file behind after a successful write', async () => {
|
|
195
|
-
const dir = await tmpDir();
|
|
196
|
-
dirs.push(dir);
|
|
197
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
198
|
-
await saveOverrides(filePath, { ttsVoice: 'aura-2-asteria-en' });
|
|
199
|
-
const files = await fs.readdir(dir);
|
|
200
|
-
const tmpFiles = files.filter((f) => f.includes('.tmp.'));
|
|
201
|
-
expect(tmpFiles).toHaveLength(0);
|
|
202
|
-
});
|
|
203
|
-
it('overwrites an existing overrides file', async () => {
|
|
204
|
-
const dir = await tmpDir();
|
|
205
|
-
dirs.push(dir);
|
|
206
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
207
|
-
await saveOverrides(filePath, { ttsVoice: 'voice-a' });
|
|
208
|
-
await saveOverrides(filePath, { ttsVoice: 'voice-b' });
|
|
209
|
-
const raw = await fs.readFile(filePath, 'utf-8');
|
|
210
|
-
expect(JSON.parse(raw)).toEqual({ ttsVoice: 'voice-b' });
|
|
211
|
-
});
|
|
212
|
-
it('preserves unknown keys while replacing known runtime override fields', async () => {
|
|
213
|
-
const dir = await tmpDir();
|
|
214
|
-
dirs.push(dir);
|
|
215
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
216
|
-
await fs.writeFile(filePath, JSON.stringify({ customFlag: true, voiceRuntime: 'claude-api', ttsVoice: 'voice-a' }), 'utf-8');
|
|
217
|
-
await saveOverrides(filePath, { fastRuntime: 'openrouter' });
|
|
218
|
-
const raw = await fs.readFile(filePath, 'utf-8');
|
|
219
|
-
expect(JSON.parse(raw)).toEqual({ customFlag: true, fastRuntime: 'openrouter' });
|
|
220
|
-
});
|
|
221
|
-
it('writes an empty overrides object', async () => {
|
|
222
|
-
const dir = await tmpDir();
|
|
223
|
-
dirs.push(dir);
|
|
224
|
-
const filePath = path.join(dir, 'runtime-overrides.json');
|
|
225
|
-
await saveOverrides(filePath, {});
|
|
226
|
-
const raw = await fs.readFile(filePath, 'utf-8');
|
|
227
|
-
expect(JSON.parse(raw)).toEqual({});
|
|
228
|
-
});
|
|
229
|
-
});
|
package/dist/tasks/store.js
CHANGED
|
@@ -227,14 +227,18 @@ export class TaskStore extends EventEmitter {
|
|
|
227
227
|
if (!prev)
|
|
228
228
|
throw new Error(`task not found: ${id}`);
|
|
229
229
|
const now = new Date().toISOString();
|
|
230
|
+
const updateParams = params;
|
|
230
231
|
const updated = {
|
|
231
232
|
...prev,
|
|
232
|
-
...(
|
|
233
|
-
...(
|
|
234
|
-
...(
|
|
235
|
-
...(
|
|
236
|
-
...(
|
|
237
|
-
...(
|
|
233
|
+
...(updateParams.title !== undefined && { title: updateParams.title }),
|
|
234
|
+
...(updateParams.description !== undefined && { description: updateParams.description }),
|
|
235
|
+
...(updateParams.priority !== undefined && { priority: updateParams.priority }),
|
|
236
|
+
...(updateParams.status !== undefined && { status: updateParams.status }),
|
|
237
|
+
...(updateParams.owner !== undefined && { owner: updateParams.owner }),
|
|
238
|
+
...(updateParams.externalRef !== undefined && { external_ref: updateParams.externalRef }),
|
|
239
|
+
...(updateParams.threadOriginGuild !== undefined && {
|
|
240
|
+
thread_origin_guild: updateParams.threadOriginGuild,
|
|
241
|
+
}),
|
|
238
242
|
updated_at: now,
|
|
239
243
|
};
|
|
240
244
|
this.tasks.set(id, updated);
|
package/dist/tasks/store.test.js
CHANGED
|
@@ -408,6 +408,50 @@ describe('TaskStore — persistence', () => {
|
|
|
408
408
|
expect(loaded.close_reason).toBe('done');
|
|
409
409
|
await fsp.default.unlink(path).catch(() => { });
|
|
410
410
|
});
|
|
411
|
+
it('persists thread origin guild when update provides it', async () => {
|
|
412
|
+
const fsp = await import('node:fs/promises');
|
|
413
|
+
const path = '/tmp/discoclaw-test-store-thread-origin-guild.jsonl';
|
|
414
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
415
|
+
const store1 = new TaskStore({ prefix: 'ws', persistPath: path });
|
|
416
|
+
const task = store1.create({ title: 'T' });
|
|
417
|
+
store1.update(task.id, {
|
|
418
|
+
externalRef: 'discord:123',
|
|
419
|
+
threadOriginGuild: 'guild-456',
|
|
420
|
+
});
|
|
421
|
+
await store1.flush();
|
|
422
|
+
const persisted = JSON.parse((await fsp.default.readFile(path, 'utf8')).trim());
|
|
423
|
+
expect(persisted.external_ref).toBe('discord:123');
|
|
424
|
+
expect(persisted.thread_origin_guild).toBe('guild-456');
|
|
425
|
+
const store2 = new TaskStore({ prefix: 'ws', persistPath: path });
|
|
426
|
+
await store2.load();
|
|
427
|
+
const loaded = store2.get(task.id);
|
|
428
|
+
expect(loaded?.thread_origin_guild).toBe('guild-456');
|
|
429
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
430
|
+
});
|
|
431
|
+
it('keeps legacy tasks without thread origin guild readable and writable', async () => {
|
|
432
|
+
const fsp = await import('node:fs/promises');
|
|
433
|
+
const path = '/tmp/discoclaw-test-store-legacy-thread-origin-guild.jsonl';
|
|
434
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
435
|
+
const legacyTask = {
|
|
436
|
+
id: 'ws-001',
|
|
437
|
+
title: 'Legacy task',
|
|
438
|
+
status: 'open',
|
|
439
|
+
external_ref: 'discord:123',
|
|
440
|
+
created_at: '2026-04-06T00:00:00.000Z',
|
|
441
|
+
updated_at: '2026-04-06T00:00:00.000Z',
|
|
442
|
+
};
|
|
443
|
+
await fsp.default.writeFile(path, `${JSON.stringify(legacyTask)}\n`, 'utf8');
|
|
444
|
+
const store = new TaskStore({ prefix: 'ws', persistPath: path });
|
|
445
|
+
await store.load();
|
|
446
|
+
const loaded = store.get('ws-001');
|
|
447
|
+
expect(loaded?.thread_origin_guild).toBeUndefined();
|
|
448
|
+
store.update('ws-001', { title: 'Legacy task updated' });
|
|
449
|
+
await store.flush();
|
|
450
|
+
const rewritten = JSON.parse((await fsp.default.readFile(path, 'utf8')).trim());
|
|
451
|
+
expect(rewritten.title).toBe('Legacy task updated');
|
|
452
|
+
expect(rewritten.thread_origin_guild).toBeUndefined();
|
|
453
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
454
|
+
});
|
|
411
455
|
it('is a no-op when no persistPath is configured', async () => {
|
|
412
456
|
const store = new TaskStore({ prefix: 'ws' });
|
|
413
457
|
store.create({ title: 'T' });
|
|
@@ -50,11 +50,11 @@ vi.mock('./task-sync-engine.js', () => {
|
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
51
|
function makeCtx() {
|
|
52
52
|
return {
|
|
53
|
-
guild: {},
|
|
53
|
+
guild: { id: 'guild-current' },
|
|
54
54
|
client: {
|
|
55
55
|
channels: {
|
|
56
56
|
cache: {
|
|
57
|
-
get: () => undefined,
|
|
57
|
+
get: vi.fn(() => undefined),
|
|
58
58
|
},
|
|
59
59
|
},
|
|
60
60
|
},
|
|
@@ -62,8 +62,8 @@ function makeCtx() {
|
|
|
62
62
|
messageId: 'msg-current',
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
65
|
+
function makeTask(id, overrides = {}) {
|
|
66
|
+
return {
|
|
67
67
|
id,
|
|
68
68
|
title: 'Test task',
|
|
69
69
|
description: 'A test',
|
|
@@ -76,34 +76,81 @@ function makeStore() {
|
|
|
76
76
|
comments: [],
|
|
77
77
|
created_at: '2026-01-01T00:00:00Z',
|
|
78
78
|
updated_at: '2026-01-01T00:00:00Z',
|
|
79
|
-
|
|
79
|
+
...overrides,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function makeStore(opts) {
|
|
83
|
+
const tasks = new Map((opts?.initialTasks ?? [
|
|
84
|
+
makeTask('ws-001', { title: 'First' }),
|
|
85
|
+
makeTask('ws-002', {
|
|
86
|
+
title: 'Second',
|
|
87
|
+
status: 'in_progress',
|
|
88
|
+
priority: 1,
|
|
89
|
+
external_ref: 'discord:222333444',
|
|
90
|
+
labels: [],
|
|
91
|
+
}),
|
|
92
|
+
]).map((task) => [task.id, task]));
|
|
80
93
|
return {
|
|
81
|
-
get: vi.fn((id) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
get: vi.fn((id) => tasks.get(id)),
|
|
95
|
+
list: vi.fn((params) => (Array.from(tasks.values()).slice(0, params?.limit ?? 50))),
|
|
96
|
+
create: vi.fn((params) => {
|
|
97
|
+
const createTaskOverrides = typeof opts?.createTaskOverrides === 'function'
|
|
98
|
+
? opts.createTaskOverrides(params)
|
|
99
|
+
: (opts?.createTaskOverrides ?? {});
|
|
100
|
+
const task = makeTask('ws-new', {
|
|
101
|
+
title: params.title,
|
|
102
|
+
description: params.description ?? '',
|
|
103
|
+
priority: params.priority ?? 2,
|
|
104
|
+
external_ref: '',
|
|
105
|
+
labels: params.labels ?? [],
|
|
106
|
+
...createTaskOverrides,
|
|
107
|
+
});
|
|
108
|
+
tasks.set(task.id, task);
|
|
109
|
+
return task;
|
|
110
|
+
}),
|
|
111
|
+
update: vi.fn((id, params) => {
|
|
112
|
+
const prev = tasks.get(id);
|
|
113
|
+
if (!prev)
|
|
114
|
+
throw new Error(`task not found: ${id}`);
|
|
115
|
+
const updated = makeTask(id, {
|
|
116
|
+
...prev,
|
|
117
|
+
...(params.title !== undefined ? { title: params.title } : {}),
|
|
118
|
+
...(params.description !== undefined ? { description: params.description } : {}),
|
|
119
|
+
...(params.priority !== undefined ? { priority: params.priority } : {}),
|
|
120
|
+
...(params.status !== undefined ? { status: params.status } : {}),
|
|
121
|
+
...(params.owner !== undefined ? { owner: params.owner } : {}),
|
|
122
|
+
...(params.externalRef !== undefined ? { external_ref: params.externalRef } : {}),
|
|
123
|
+
...(params.threadOriginGuild !== undefined ? { thread_origin_guild: params.threadOriginGuild } : {}),
|
|
124
|
+
updated_at: '2026-01-02T00:00:00Z',
|
|
125
|
+
});
|
|
126
|
+
tasks.set(id, updated);
|
|
127
|
+
return updated;
|
|
128
|
+
}),
|
|
129
|
+
close: vi.fn((id) => {
|
|
130
|
+
const prev = tasks.get(id);
|
|
131
|
+
if (!prev)
|
|
132
|
+
throw new Error(`task not found: ${id}`);
|
|
133
|
+
const updated = makeTask(id, {
|
|
134
|
+
...prev,
|
|
135
|
+
status: 'closed',
|
|
136
|
+
closed_at: '2026-01-02T00:00:00Z',
|
|
137
|
+
updated_at: '2026-01-02T00:00:00Z',
|
|
138
|
+
});
|
|
139
|
+
tasks.set(id, updated);
|
|
140
|
+
return updated;
|
|
141
|
+
}),
|
|
142
|
+
addLabel: vi.fn((id, label) => {
|
|
143
|
+
const prev = tasks.get(id);
|
|
144
|
+
if (!prev)
|
|
145
|
+
throw new Error(`task not found: ${id}`);
|
|
146
|
+
const updated = makeTask(id, {
|
|
147
|
+
...prev,
|
|
148
|
+
labels: [...new Set([...(prev.labels ?? []), label])],
|
|
149
|
+
updated_at: '2026-01-02T00:00:00Z',
|
|
150
|
+
});
|
|
151
|
+
tasks.set(id, updated);
|
|
152
|
+
return updated;
|
|
85
153
|
}),
|
|
86
|
-
list: vi.fn(() => [
|
|
87
|
-
{ id: 'ws-001', title: 'First', status: 'open', priority: 2 },
|
|
88
|
-
{ id: 'ws-002', title: 'Second', status: 'in_progress', priority: 1 },
|
|
89
|
-
]),
|
|
90
|
-
create: vi.fn((params) => ({
|
|
91
|
-
id: 'ws-new',
|
|
92
|
-
title: params.title,
|
|
93
|
-
description: params.description ?? '',
|
|
94
|
-
status: 'open',
|
|
95
|
-
priority: params.priority ?? 2,
|
|
96
|
-
issue_type: 'task',
|
|
97
|
-
owner: '',
|
|
98
|
-
external_ref: '',
|
|
99
|
-
labels: params.labels ?? [],
|
|
100
|
-
comments: [],
|
|
101
|
-
created_at: '2026-01-01T00:00:00Z',
|
|
102
|
-
updated_at: '2026-01-01T00:00:00Z',
|
|
103
|
-
})),
|
|
104
|
-
update: vi.fn((id) => defaultTask(id)),
|
|
105
|
-
close: vi.fn((id) => ({ ...defaultTask(id), status: 'closed' })),
|
|
106
|
-
addLabel: vi.fn((id) => defaultTask(id)),
|
|
107
154
|
reload: vi.fn(async () => ({ added: [], updated: [], removed: [] })),
|
|
108
155
|
};
|
|
109
156
|
}
|
|
@@ -137,11 +184,19 @@ describe('TASK_ACTION_TYPES', () => {
|
|
|
137
184
|
});
|
|
138
185
|
});
|
|
139
186
|
describe('executeTaskAction', () => {
|
|
140
|
-
it('taskCreate returns
|
|
187
|
+
it('taskCreate returns a real Discord thread URL and structured thread metadata', async () => {
|
|
141
188
|
const result = await executeTaskAction({ type: 'taskCreate', title: 'New task', priority: 1 }, makeCtx(), makeTaskCtx());
|
|
189
|
+
const success = result;
|
|
142
190
|
expect(result.ok).toBe(true);
|
|
143
|
-
expect(
|
|
144
|
-
expect(
|
|
191
|
+
expect(success.summary).toContain('ws-new');
|
|
192
|
+
expect(success.summary).toContain('New task');
|
|
193
|
+
expect(success.summary).toContain('Thread: https://discord.com/channels/guild-current/thread-new');
|
|
194
|
+
expect(success.thread).toEqual({
|
|
195
|
+
externalRef: 'discord:thread-new',
|
|
196
|
+
threadId: 'thread-new',
|
|
197
|
+
threadGuildId: 'guild-current',
|
|
198
|
+
threadUrl: 'https://discord.com/channels/guild-current/thread-new',
|
|
199
|
+
});
|
|
145
200
|
});
|
|
146
201
|
it('taskCreate calls forumCountSync.requestUpdate', async () => {
|
|
147
202
|
const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
|
|
@@ -157,28 +212,29 @@ describe('executeTaskAction', () => {
|
|
|
157
212
|
createTaskThread.mockClear?.();
|
|
158
213
|
const result = await executeTaskAction({ type: 'taskCreate', title: 'No thread please', tags: 'no-thread,feature' }, makeCtx(), makeTaskCtx());
|
|
159
214
|
expect(result.ok).toBe(true);
|
|
215
|
+
expect(result.summary).not.toContain('Thread:');
|
|
216
|
+
expect(result.thread).toBeUndefined();
|
|
160
217
|
expect(createTaskThread).not.toHaveBeenCalled();
|
|
161
218
|
});
|
|
162
|
-
it('taskCreate
|
|
219
|
+
it('taskCreate backfills missing origin guild data for already-linked tasks', async () => {
|
|
163
220
|
const { createTaskThread } = await import('./thread-ops.js');
|
|
164
221
|
createTaskThread.mockClear?.();
|
|
165
|
-
const store = makeStore(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
issue_type: 'task',
|
|
172
|
-
owner: '',
|
|
173
|
-
external_ref: 'discord:thread-existing',
|
|
174
|
-
labels: ['feature'],
|
|
175
|
-
comments: [],
|
|
176
|
-
created_at: '2026-01-01T00:00:00Z',
|
|
177
|
-
updated_at: '2026-01-01T00:00:00Z',
|
|
178
|
-
}));
|
|
222
|
+
const store = makeStore({
|
|
223
|
+
createTaskOverrides: {
|
|
224
|
+
title: 'Already linked',
|
|
225
|
+
external_ref: 'discord:thread-existing',
|
|
226
|
+
},
|
|
227
|
+
});
|
|
179
228
|
const result = await executeTaskAction({ type: 'taskCreate', title: 'Task already linked' }, makeCtx(), makeTaskCtx({ store: store }));
|
|
180
229
|
expect(result.ok).toBe(true);
|
|
181
|
-
expect(result.summary).toContain('thread
|
|
230
|
+
expect(result.summary).toContain('Thread: https://discord.com/channels/guild-current/thread-existing');
|
|
231
|
+
expect(result.thread).toEqual({
|
|
232
|
+
externalRef: 'discord:thread-existing',
|
|
233
|
+
threadId: 'thread-existing',
|
|
234
|
+
threadGuildId: 'guild-current',
|
|
235
|
+
threadUrl: 'https://discord.com/channels/guild-current/thread-existing',
|
|
236
|
+
});
|
|
237
|
+
expect(store.update).toHaveBeenCalledWith('ws-new', { threadOriginGuild: 'guild-current' });
|
|
182
238
|
expect(createTaskThread).not.toHaveBeenCalled();
|
|
183
239
|
});
|
|
184
240
|
it('taskUpdate returns updated summary', async () => {
|
|
@@ -301,9 +357,65 @@ describe('executeTaskAction', () => {
|
|
|
301
357
|
it('taskShow returns task details', async () => {
|
|
302
358
|
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx());
|
|
303
359
|
expect(result.ok).toBe(true);
|
|
304
|
-
expect(result.summary).toContain('
|
|
360
|
+
expect(result.summary).toContain('First');
|
|
305
361
|
expect(result.summary).toContain('ws-001');
|
|
306
362
|
});
|
|
363
|
+
it('taskShow shows External ref for linked tasks even without canonical thread URL data', async () => {
|
|
364
|
+
const store = makeStore({
|
|
365
|
+
initialTasks: [
|
|
366
|
+
makeTask('ws-001', {
|
|
367
|
+
title: 'Linked elsewhere',
|
|
368
|
+
external_ref: 'gh:123',
|
|
369
|
+
thread_origin_guild: undefined,
|
|
370
|
+
}),
|
|
371
|
+
],
|
|
372
|
+
});
|
|
373
|
+
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ store: store }));
|
|
374
|
+
expect(result.ok).toBe(true);
|
|
375
|
+
expect(result.summary).toContain('External ref: gh:123');
|
|
376
|
+
expect(result.summary).not.toContain('Thread:');
|
|
377
|
+
expect(result.thread).toEqual({ externalRef: 'gh:123' });
|
|
378
|
+
});
|
|
379
|
+
it('taskShow only emits Thread when stored origin guild data exists', async () => {
|
|
380
|
+
const store = makeStore({
|
|
381
|
+
initialTasks: [
|
|
382
|
+
makeTask('ws-001', {
|
|
383
|
+
title: 'Thread-linked',
|
|
384
|
+
external_ref: 'discord:111222333',
|
|
385
|
+
thread_origin_guild: 'guild-stored',
|
|
386
|
+
}),
|
|
387
|
+
],
|
|
388
|
+
});
|
|
389
|
+
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ store: store }));
|
|
390
|
+
expect(result.ok).toBe(true);
|
|
391
|
+
expect(result.summary).toContain('External ref: discord:111222333');
|
|
392
|
+
expect(result.summary).toContain('Thread: https://discord.com/channels/guild-stored/111222333');
|
|
393
|
+
expect(result.thread).toEqual({
|
|
394
|
+
externalRef: 'discord:111222333',
|
|
395
|
+
threadId: '111222333',
|
|
396
|
+
threadGuildId: 'guild-stored',
|
|
397
|
+
threadUrl: 'https://discord.com/channels/guild-stored/111222333',
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
it('taskShow does not perform live Discord lookups', async () => {
|
|
401
|
+
const { resolveTasksForum } = await import('./thread-ops.js');
|
|
402
|
+
resolveTasksForum.mockClear?.();
|
|
403
|
+
const ctx = makeCtx();
|
|
404
|
+
const cacheGet = ctx.client.channels.cache.get;
|
|
405
|
+
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, ctx, makeTaskCtx({
|
|
406
|
+
store: makeStore({
|
|
407
|
+
initialTasks: [
|
|
408
|
+
makeTask('ws-001', {
|
|
409
|
+
external_ref: 'discord:111222333',
|
|
410
|
+
thread_origin_guild: 'guild-stored',
|
|
411
|
+
}),
|
|
412
|
+
],
|
|
413
|
+
}),
|
|
414
|
+
}));
|
|
415
|
+
expect(result.ok).toBe(true);
|
|
416
|
+
expect(resolveTasksForum).not.toHaveBeenCalled();
|
|
417
|
+
expect(cacheGet).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
307
419
|
it('taskShow fails for unknown task', async () => {
|
|
308
420
|
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-notfound' }, makeCtx(), makeTaskCtx());
|
|
309
421
|
expect(result.ok).toBe(false);
|
|
@@ -4,6 +4,7 @@ import { autoTagTask } from './auto-tag.js';
|
|
|
4
4
|
import { taskThreadCache } from './thread-cache.js';
|
|
5
5
|
import { resolveTaskId, resolveTaskService, scheduleRepairSync, } from './task-action-mutation-helpers.js';
|
|
6
6
|
import { ensureCreatedTaskThreadLink, syncClosedTaskThread, syncUpdatedTaskThread, } from './task-action-thread-sync.js';
|
|
7
|
+
import { getTaskActionThreadMetadata } from './task-action-runner-types.js';
|
|
7
8
|
/** Pre-computed set for filtering status names from tag candidates. */
|
|
8
9
|
const STATUS_NAME_SET = new Set(TASK_STATUSES);
|
|
9
10
|
/**
|
|
@@ -73,10 +74,29 @@ export async function handleTaskCreate(action, ctx, taskCtx) {
|
|
|
73
74
|
if (needsRepairSync) {
|
|
74
75
|
scheduleRepairSync(taskCtx, task.id, ctx);
|
|
75
76
|
}
|
|
77
|
+
let linkedTask = taskCtx.store.get(task.id) ?? task;
|
|
78
|
+
const storedThread = getTaskActionThreadMetadata(linkedTask);
|
|
79
|
+
const linkedThreadId = storedThread?.threadId ?? (threadId || undefined);
|
|
80
|
+
const guildId = ctx.guild.id?.trim() || undefined;
|
|
81
|
+
if (linkedThreadId && guildId && !linkedTask.thread_origin_guild) {
|
|
82
|
+
try {
|
|
83
|
+
linkedTask = taskService.update(task.id, {
|
|
84
|
+
...(storedThread ? {} : { externalRef: `discord:${linkedThreadId}` }),
|
|
85
|
+
threadOriginGuild: guildId,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
taskCtx.log?.warn({ err, taskId: task.id, threadId: linkedThreadId, guildId }, 'tasks:thread origin guild update failed');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const thread = getTaskActionThreadMetadata(linkedTask);
|
|
76
93
|
taskThreadCache.invalidate();
|
|
77
94
|
taskCtx.forumCountSync?.requestUpdate();
|
|
78
|
-
const
|
|
79
|
-
|
|
95
|
+
const summary = [
|
|
96
|
+
`Task ${task.id} created: "${task.title}"`,
|
|
97
|
+
...(thread?.threadUrl ? [`Thread: ${thread.threadUrl}`] : []),
|
|
98
|
+
].join('\n');
|
|
99
|
+
return { ok: true, summary, ...(thread ? { thread } : {}) };
|
|
80
100
|
}
|
|
81
101
|
export async function handleTaskUpdate(action, ctx, taskCtx) {
|
|
82
102
|
const taskId = resolveTaskId(action);
|