kimaki 0.28.0 → 0.29.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/dist/bash-tool-schema-plugin.js +18 -0
- package/dist/bash-tool-schema-plugin.test.js +20 -1
- package/dist/channel-management.js +113 -51
- package/dist/cli-commands/misc.js +4 -1
- package/dist/cli-commands/project.js +7 -4
- package/dist/cli-commands/session.js +84 -33
- package/dist/cli-commands/thread.js +63 -0
- package/dist/cli.js +2 -0
- package/dist/commands/queue.js +79 -31
- package/dist/commands/queue.test.js +15 -0
- package/dist/commands/restart-opencode-server.js +7 -1
- package/dist/commands/session-id.js +5 -5
- package/dist/database.js +76 -0
- package/dist/db.js +34 -0
- package/dist/db.test.js +96 -1
- package/dist/default-channel-provisioning.test.js +2 -1
- package/dist/discord-bot.js +9 -3
- package/dist/discord-command-registration.js +1 -1
- package/dist/guild-categories.test.js +225 -0
- package/dist/interaction-handler.js +5 -1
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kimaki-opencode-plugin.test.js +28 -0
- package/dist/list-channel-threads.js +163 -0
- package/dist/list-channel-threads.test.js +80 -0
- package/dist/message-formatting.js +9 -16
- package/dist/message-formatting.test.js +91 -2
- package/dist/schema.js +21 -0
- package/dist/session-handler/event-stream-state.js +53 -5
- package/dist/session-handler/event-stream-state.test.js +223 -0
- package/dist/session-handler/thread-runtime-state.js +4 -0
- package/dist/session-handler/thread-session-runtime.js +314 -73
- package/dist/system-message.js +55 -13
- package/dist/system-message.test.js +50 -13
- package/dist/task-id-plugin.js +16 -0
- package/package.json +5 -5
- package/skills/strada/SKILL.md +61 -232
- package/src/bash-tool-schema-plugin.test.ts +29 -0
- package/src/bash-tool-schema-plugin.ts +31 -0
- package/src/channel-management.ts +144 -65
- package/src/cli-commands/misc.ts +4 -1
- package/src/cli-commands/project.ts +9 -4
- package/src/cli-commands/session.ts +123 -34
- package/src/cli-commands/thread.ts +86 -0
- package/src/cli.ts +2 -0
- package/src/commands/queue.test.ts +19 -0
- package/src/commands/queue.ts +106 -40
- package/src/commands/restart-opencode-server.ts +12 -1
- package/src/commands/session-id.ts +5 -5
- package/src/database.ts +114 -0
- package/src/db.test.ts +112 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +2 -1
- package/src/discord-bot.ts +11 -2
- package/src/discord-command-registration.ts +3 -1
- package/src/guild-categories.test.ts +260 -0
- package/src/interaction-handler.ts +7 -0
- package/src/kimaki-opencode-plugin.test.ts +34 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/list-channel-threads.test.ts +90 -0
- package/src/list-channel-threads.ts +252 -0
- package/src/message-formatting.test.ts +105 -1
- package/src/message-formatting.ts +7 -12
- package/src/schema.sql +16 -0
- package/src/schema.ts +23 -0
- package/src/session-handler/event-stream-state.test.ts +226 -0
- package/src/session-handler/event-stream-state.ts +57 -5
- package/src/session-handler/thread-runtime-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +380 -81
- package/src/system-message.test.ts +50 -13
- package/src/system-message.ts +58 -12
- package/src/task-id-plugin.ts +17 -0
package/dist/commands/queue.js
CHANGED
|
@@ -1,44 +1,94 @@
|
|
|
1
1
|
// Queue commands - /queue, /queue-command
|
|
2
2
|
// Clear/remove actions live as buttons on each queue confirmation message.
|
|
3
3
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, MessageFlags, } from 'discord.js';
|
|
4
|
-
import { getThreadSession } from '../database.js';
|
|
4
|
+
import { deleteThreadQueueItem, getThreadSession } from '../database.js';
|
|
5
5
|
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
6
6
|
import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
|
|
7
|
-
import { buildHtmlActionCustomId, registerHtmlAction, } from '../html-actions.js';
|
|
8
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
8
|
import { QUEUE_PREFIX } from '../message-formatting.js';
|
|
10
9
|
import { store } from '../store.js';
|
|
11
10
|
const logger = createLogger(LogPrefix.QUEUE);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const removed = getRuntime(threadId)?.removeQueueItemById(queueId);
|
|
18
|
-
if (!removed) {
|
|
19
|
-
await interaction.editReply({
|
|
20
|
-
content: position
|
|
21
|
-
? `Queued message at position ${position} is no longer in the queue`
|
|
22
|
-
: 'Queued message is no longer in the queue',
|
|
23
|
-
components: [],
|
|
24
|
-
});
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
const label = removed.command
|
|
28
|
-
? `/${removed.command.name}`
|
|
29
|
-
: removed.prompt.slice(0, 120);
|
|
30
|
-
await interaction.editReply({
|
|
31
|
-
content: `Removed queued message${position ? ` (was position ${position})` : ''}: ${label}`,
|
|
32
|
-
components: [],
|
|
33
|
-
});
|
|
34
|
-
logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${queueId} in thread ${threadId}`);
|
|
35
|
-
},
|
|
11
|
+
export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:';
|
|
12
|
+
async function deleteThreadQueueItemWithoutRuntime(queueId) {
|
|
13
|
+
const row = await deleteThreadQueueItem(queueId).catch((error) => {
|
|
14
|
+
logger.error(`[QUEUE] Failed to delete persisted queue item ${queueId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
15
|
+
return undefined;
|
|
36
16
|
});
|
|
17
|
+
if (!row) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(row.payload_json);
|
|
22
|
+
return {
|
|
23
|
+
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
|
|
24
|
+
username: typeof parsed.username === 'string' ? parsed.username : '',
|
|
25
|
+
command: parsed.command,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { prompt: '', username: '' };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function buildQueueRemoveCustomId({ threadId, queueId, }) {
|
|
33
|
+
return `${QUEUE_REMOVE_CUSTOM_ID_PREFIX}${threadId}:${queueId}`;
|
|
34
|
+
}
|
|
35
|
+
export function parseQueueRemoveCustomId(customId) {
|
|
36
|
+
if (!customId.startsWith(QUEUE_REMOVE_CUSTOM_ID_PREFIX)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const rest = customId.slice(QUEUE_REMOVE_CUSTOM_ID_PREFIX.length);
|
|
40
|
+
const separator = rest.indexOf(':');
|
|
41
|
+
if (separator <= 0 || separator >= rest.length - 1) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
threadId: rest.slice(0, separator),
|
|
46
|
+
queueId: rest.slice(separator + 1),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function buildQueueRemoveRow({ threadId, queueId, }) {
|
|
37
50
|
return new ActionRowBuilder().addComponents(new ButtonBuilder()
|
|
38
|
-
.setCustomId(
|
|
51
|
+
.setCustomId(buildQueueRemoveCustomId({ threadId, queueId }))
|
|
39
52
|
.setLabel('Remove from queue')
|
|
40
53
|
.setStyle(ButtonStyle.Secondary));
|
|
41
54
|
}
|
|
55
|
+
export async function handleQueueRemoveButton(interaction) {
|
|
56
|
+
const parsed = parseQueueRemoveCustomId(interaction.customId);
|
|
57
|
+
if (!parsed) {
|
|
58
|
+
await interaction.reply({
|
|
59
|
+
content: 'Invalid queue remove button',
|
|
60
|
+
flags: MessageFlags.Ephemeral,
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (interaction.channelId && interaction.channelId !== parsed.threadId) {
|
|
65
|
+
await interaction.reply({
|
|
66
|
+
content: 'This queue button does not belong to this thread',
|
|
67
|
+
flags: MessageFlags.Ephemeral,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
await interaction.deferUpdate();
|
|
72
|
+
const runtime = getRuntime(parsed.threadId);
|
|
73
|
+
const removed = runtime
|
|
74
|
+
? await runtime.removeQueueItemById(parsed.queueId)
|
|
75
|
+
: await deleteThreadQueueItemWithoutRuntime(parsed.queueId);
|
|
76
|
+
if (!removed) {
|
|
77
|
+
await interaction.editReply({
|
|
78
|
+
content: 'Queued message is no longer in the queue',
|
|
79
|
+
components: [],
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const label = removed.command
|
|
84
|
+
? `/${removed.command.name}`
|
|
85
|
+
: removed.prompt.slice(0, 120);
|
|
86
|
+
await interaction.editReply({
|
|
87
|
+
content: `Removed queued message: ${label}`,
|
|
88
|
+
components: [],
|
|
89
|
+
});
|
|
90
|
+
logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`);
|
|
91
|
+
}
|
|
42
92
|
export async function handleQueueCommand({ command, appId, }) {
|
|
43
93
|
const message = command.options.getString('message', true);
|
|
44
94
|
const channel = command.channel;
|
|
@@ -102,7 +152,6 @@ export async function handleQueueCommand({ command, appId, }) {
|
|
|
102
152
|
buildQueueRemoveRow({
|
|
103
153
|
threadId: thread.id,
|
|
104
154
|
queueId: enqueueResult.queueId,
|
|
105
|
-
position: enqueueResult.position,
|
|
106
155
|
}),
|
|
107
156
|
],
|
|
108
157
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -147,7 +196,7 @@ export async function handleClearQueueCommand({ command, }) {
|
|
|
147
196
|
return;
|
|
148
197
|
}
|
|
149
198
|
if (position !== undefined) {
|
|
150
|
-
const removed = runtime?.removeQueuePosition(position);
|
|
199
|
+
const removed = await runtime?.removeQueuePosition(position);
|
|
151
200
|
if (!removed) {
|
|
152
201
|
await command.reply({
|
|
153
202
|
content: `No queued message at position ${position}`,
|
|
@@ -162,7 +211,7 @@ export async function handleClearQueueCommand({ command, }) {
|
|
|
162
211
|
logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
|
|
163
212
|
return;
|
|
164
213
|
}
|
|
165
|
-
const cleared = runtime?.clearQueue() ?? [];
|
|
214
|
+
const cleared = await runtime?.clearQueue() ?? [];
|
|
166
215
|
const lines = cleared.map((item, i) => {
|
|
167
216
|
const label = item.command
|
|
168
217
|
? `/${item.command.name}`
|
|
@@ -257,7 +306,6 @@ export async function handleQueueCommandCommand({ command, appId, }) {
|
|
|
257
306
|
buildQueueRemoveRow({
|
|
258
307
|
threadId: thread.id,
|
|
259
308
|
queueId: enqueueResult.queueId,
|
|
260
|
-
position: enqueueResult.position,
|
|
261
309
|
}),
|
|
262
310
|
],
|
|
263
311
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { buildQueueRemoveCustomId, parseQueueRemoveCustomId, } from './queue.js';
|
|
3
|
+
describe('queue remove custom id', () => {
|
|
4
|
+
test('round-trips thread and queue ids under Discord custom_id limit', () => {
|
|
5
|
+
const customId = buildQueueRemoveCustomId({
|
|
6
|
+
threadId: '1550106184055398531',
|
|
7
|
+
queueId: 'a1b2c3d4e5f60789',
|
|
8
|
+
});
|
|
9
|
+
expect(customId.length).toBeLessThanOrEqual(100);
|
|
10
|
+
expect(parseQueueRemoveCustomId(customId)).toEqual({
|
|
11
|
+
threadId: '1550106184055398531',
|
|
12
|
+
queueId: 'a1b2c3d4e5f60789',
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -9,7 +9,7 @@ import { ChannelType, MessageFlags, } from 'discord.js';
|
|
|
9
9
|
import { initializeOpencodeForDirectory, restartOpencodeServer } from '../opencode.js';
|
|
10
10
|
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
11
11
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
12
|
-
import { disposeRuntimesForDirectory } from '../session-handler/thread-session-runtime.js';
|
|
12
|
+
import { disposeRuntimesForDirectory, restorePersistedLocalQueues, } from '../session-handler/thread-session-runtime.js';
|
|
13
13
|
import { registerCommands } from '../discord-command-registration.js';
|
|
14
14
|
const logger = createLogger(LogPrefix.OPENCODE);
|
|
15
15
|
export async function handleRestartOpencodeServerCommand({ command, appId, }) {
|
|
@@ -74,6 +74,12 @@ export async function handleRestartOpencodeServerCommand({ command, appId, }) {
|
|
|
74
74
|
content: `Opencode server **restarted** successfully${abortMsg}. Re-registering slash commands...`,
|
|
75
75
|
});
|
|
76
76
|
logger.log('[RESTART] Shared opencode server restarted');
|
|
77
|
+
await restorePersistedLocalQueues({
|
|
78
|
+
discordClient: command.client,
|
|
79
|
+
appId,
|
|
80
|
+
}).catch((error) => {
|
|
81
|
+
logger.warn(`[RESTART] Failed to restore persisted queues: ${error instanceof Error ? error.message : String(error)}`);
|
|
82
|
+
});
|
|
77
83
|
// Re-register Discord slash commands after restart so new/changed
|
|
78
84
|
// commands, agents, and plugins are picked up immediately.
|
|
79
85
|
const token = command.client.token;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// /session-id command - Show current session ID and an opencode attach command.
|
|
1
|
+
// /session-id command - Show current session ID, Discord thread ID, and an opencode attach command.
|
|
2
2
|
import { ChannelType, MessageFlags, } from 'discord.js';
|
|
3
3
|
import { getThreadSession } from '../database.js';
|
|
4
4
|
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
@@ -46,7 +46,7 @@ export async function handleSessionIdCommand({ command, }) {
|
|
|
46
46
|
const sessionId = await getThreadSession(channel.id);
|
|
47
47
|
if (!sessionId) {
|
|
48
48
|
await command.reply({
|
|
49
|
-
content:
|
|
49
|
+
content: `No active session in this thread\n**Thread ID:** \`${channel.id}\``,
|
|
50
50
|
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
51
51
|
});
|
|
52
52
|
return;
|
|
@@ -57,7 +57,7 @@ export async function handleSessionIdCommand({ command, }) {
|
|
|
57
57
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
58
58
|
if (getClient instanceof Error) {
|
|
59
59
|
await command.editReply({
|
|
60
|
-
content:
|
|
60
|
+
content: `**Session ID:** \`${sessionId}\`\n**Thread ID:** \`${channel.id}\`\nFailed to resolve OpenCode server port: ${getClient.message}`,
|
|
61
61
|
});
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
@@ -65,14 +65,14 @@ export async function handleSessionIdCommand({ command, }) {
|
|
|
65
65
|
}
|
|
66
66
|
if (!port) {
|
|
67
67
|
await command.editReply({
|
|
68
|
-
content:
|
|
68
|
+
content: `**Session ID:** \`${sessionId}\`\n**Thread ID:** \`${channel.id}\`\nCould not determine OpenCode server port`,
|
|
69
69
|
});
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
const attachUrl = `http://127.0.0.1:${port}`;
|
|
73
73
|
const attachCommand = `opencode attach ${attachUrl} --session ${sessionId} --dir ${shellQuote(workingDirectory)}`;
|
|
74
74
|
await command.editReply({
|
|
75
|
-
content: `**Session ID:** \`${sessionId}\`\n**Attach command:**\n\`\`\`bash\n${attachCommand}\n\`\`\``,
|
|
75
|
+
content: `**Session ID:** \`${sessionId}\`\n**Thread ID:** \`${channel.id}\`\n**Attach command:**\n\`\`\`bash\n${attachCommand}\n\`\`\``,
|
|
76
76
|
});
|
|
77
77
|
logger.log(`Session ID shown for thread ${channel.id}: ${sessionId}`);
|
|
78
78
|
}
|
package/dist/database.js
CHANGED
|
@@ -542,6 +542,42 @@ export async function getThreadSession(threadId) {
|
|
|
542
542
|
const db = await getDb();
|
|
543
543
|
return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId } }))?.session_id;
|
|
544
544
|
}
|
|
545
|
+
export async function insertThreadQueueItem({ queueId, threadId, payloadJson, }) {
|
|
546
|
+
const db = await getDb();
|
|
547
|
+
await db.insert(schema.thread_queue_items).values({
|
|
548
|
+
queue_id: queueId,
|
|
549
|
+
thread_id: threadId,
|
|
550
|
+
payload_json: payloadJson,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
export async function listThreadQueueItems(threadId) {
|
|
554
|
+
const db = await getDb();
|
|
555
|
+
return db.query.thread_queue_items.findMany({
|
|
556
|
+
where: { thread_id: threadId },
|
|
557
|
+
orderBy: { id: 'asc' },
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
export async function listAllThreadQueueItems() {
|
|
561
|
+
const db = await getDb();
|
|
562
|
+
return db.query.thread_queue_items.findMany({
|
|
563
|
+
orderBy: { id: 'asc' },
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
export async function deleteThreadQueueItem(queueId) {
|
|
567
|
+
const db = await getDb();
|
|
568
|
+
const rows = await db.delete(schema.thread_queue_items).where(orm.eq(schema.thread_queue_items.queue_id, queueId)).returning();
|
|
569
|
+
return rows[0];
|
|
570
|
+
}
|
|
571
|
+
export async function deleteThreadQueueItems(threadId) {
|
|
572
|
+
const db = await getDb();
|
|
573
|
+
await db.delete(schema.thread_queue_items).where(orm.eq(schema.thread_queue_items.thread_id, threadId));
|
|
574
|
+
}
|
|
575
|
+
export async function updateThreadQueueItemPayload({ queueId, payloadJson, }) {
|
|
576
|
+
const db = await getDb();
|
|
577
|
+
await db.update(schema.thread_queue_items)
|
|
578
|
+
.set({ payload_json: payloadJson })
|
|
579
|
+
.where(orm.eq(schema.thread_queue_items.queue_id, queueId));
|
|
580
|
+
}
|
|
545
581
|
export async function setThreadSession(threadId, sessionId) {
|
|
546
582
|
await upsertThreadSession({ threadId, sessionId, source: 'kimaki' });
|
|
547
583
|
}
|
|
@@ -833,6 +869,46 @@ export async function listTrackedTextChannels() {
|
|
|
833
869
|
columns: { channel_id: true, directory: true, created_at: true },
|
|
834
870
|
});
|
|
835
871
|
}
|
|
872
|
+
export async function getGuildCategories(guildId) {
|
|
873
|
+
const db = await getDb();
|
|
874
|
+
const row = await db.query.guild_categories.findFirst({ where: { guild_id: guildId } });
|
|
875
|
+
if (!row)
|
|
876
|
+
return null;
|
|
877
|
+
return {
|
|
878
|
+
guild_id: row.guild_id,
|
|
879
|
+
category_id: row.category_id,
|
|
880
|
+
audio_category_id: row.audio_category_id,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
export async function listGuildCategoryIds() {
|
|
884
|
+
const db = await getDb();
|
|
885
|
+
const rows = await db.query.guild_categories.findMany({
|
|
886
|
+
columns: { category_id: true, audio_category_id: true },
|
|
887
|
+
});
|
|
888
|
+
const ids = new Set();
|
|
889
|
+
for (const row of rows) {
|
|
890
|
+
if (row.category_id)
|
|
891
|
+
ids.add(row.category_id);
|
|
892
|
+
if (row.audio_category_id)
|
|
893
|
+
ids.add(row.audio_category_id);
|
|
894
|
+
}
|
|
895
|
+
return [...ids];
|
|
896
|
+
}
|
|
897
|
+
export async function setGuildCategoryId({ guildId, kind, categoryId, }) {
|
|
898
|
+
const db = await getDb();
|
|
899
|
+
const values = kind === 'audio'
|
|
900
|
+
? { guild_id: guildId, audio_category_id: categoryId }
|
|
901
|
+
: { guild_id: guildId, category_id: categoryId };
|
|
902
|
+
const set = kind === 'audio'
|
|
903
|
+
? { audio_category_id: categoryId }
|
|
904
|
+
: { category_id: categoryId };
|
|
905
|
+
await db.insert(schema.guild_categories)
|
|
906
|
+
.values(values)
|
|
907
|
+
.onConflictDoUpdate({
|
|
908
|
+
target: schema.guild_categories.guild_id,
|
|
909
|
+
set,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
836
912
|
export async function deleteChannelDirectoriesByDirectory(directory) {
|
|
837
913
|
const db = await getDb();
|
|
838
914
|
await db.delete(schema.channel_directories).where(orm.eq(schema.channel_directories.directory, directory));
|
package/dist/db.js
CHANGED
|
@@ -89,6 +89,37 @@ async function initializeDb() {
|
|
|
89
89
|
dbInstance = db;
|
|
90
90
|
return db;
|
|
91
91
|
}
|
|
92
|
+
async function rebuildLegacyThreadQueueItems(client) {
|
|
93
|
+
const info = await client.execute('PRAGMA table_info(thread_queue_items)').catch(() => undefined);
|
|
94
|
+
if (!info) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const columns = new Set(info.rows.map((row) => String(row.name)));
|
|
98
|
+
if (columns.has('id')) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!columns.has('queue_id')) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await client.execute('ALTER TABLE thread_queue_items RENAME TO thread_queue_items_legacy');
|
|
105
|
+
await client.execute(`
|
|
106
|
+
CREATE TABLE IF NOT EXISTS thread_queue_items (
|
|
107
|
+
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
108
|
+
queue_id text NOT NULL UNIQUE,
|
|
109
|
+
thread_id text NOT NULL,
|
|
110
|
+
payload_json text NOT NULL,
|
|
111
|
+
created_at datetime DEFAULT CURRENT_TIMESTAMP
|
|
112
|
+
)
|
|
113
|
+
`);
|
|
114
|
+
await client.execute(`
|
|
115
|
+
INSERT INTO thread_queue_items (queue_id, thread_id, payload_json, created_at)
|
|
116
|
+
SELECT queue_id, thread_id, payload_json, created_at
|
|
117
|
+
FROM thread_queue_items_legacy
|
|
118
|
+
ORDER BY created_at, queue_id
|
|
119
|
+
`);
|
|
120
|
+
await client.execute('DROP TABLE thread_queue_items_legacy');
|
|
121
|
+
await client.execute('CREATE INDEX IF NOT EXISTS thread_queue_items_thread_id_id_idx ON thread_queue_items (thread_id, id)');
|
|
122
|
+
}
|
|
92
123
|
// CREATE TABLE IF NOT EXISTS cannot drop columns. The first two session_sleeps
|
|
93
124
|
// shapes kept thread_id NOT NULL and posted_at. Inserts now omit thread_id, so
|
|
94
125
|
// existing DBs fail with SQLITE_CONSTRAINT_NOTNULL until those columns go away.
|
|
@@ -104,6 +135,9 @@ async function dropLegacySessionSleepsColumns(client) {
|
|
|
104
135
|
await client.execute("UPDATE session_sleeps SET status = 'planned' WHERE status = 'posted'");
|
|
105
136
|
}
|
|
106
137
|
async function migrateSchema({ db, client, }) {
|
|
138
|
+
// schema.sql CREATE TABLE IF NOT EXISTS cannot change the primary key.
|
|
139
|
+
// Rebuild first so CREATE INDEX (... id) does not hit the old queue_id table.
|
|
140
|
+
await rebuildLegacyThreadQueueItems(client);
|
|
107
141
|
const schemaPath = path.join(__dirname, '../src/schema.sql');
|
|
108
142
|
const sql = fs.readFileSync(schemaPath, 'utf-8');
|
|
109
143
|
const statements = sql
|
package/dist/db.test.js
CHANGED
|
@@ -7,7 +7,7 @@ import { afterAll, describe, expect, test } from 'vitest';
|
|
|
7
7
|
import { closeDb, getDb } from './db.js';
|
|
8
8
|
import * as orm from 'drizzle-orm';
|
|
9
9
|
import * as schema from './schema.js';
|
|
10
|
-
import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, setSessionModel, upsertSessionSleep, } from './database.js';
|
|
10
|
+
import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, deleteThreadQueueItem, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setSessionModel, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
|
|
11
11
|
import { createClient } from '@libsql/client';
|
|
12
12
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
13
13
|
import { chooseLockPort } from './test-utils.js';
|
|
@@ -27,6 +27,101 @@ describe('getDb', () => {
|
|
|
27
27
|
].map((match) => match[1]);
|
|
28
28
|
expect(new Set(tablesFromSql)).toEqual(new Set(tablesFromTs));
|
|
29
29
|
expect(tablesFromSql).toContain('session_sleeps');
|
|
30
|
+
expect(tablesFromSql).toContain('thread_queue_items');
|
|
31
|
+
});
|
|
32
|
+
test('persists local queue items in FIFO order', async () => {
|
|
33
|
+
const db = await getDb();
|
|
34
|
+
const threadId = `test-queue-thread-${crypto.randomUUID()}`;
|
|
35
|
+
const firstId = `queue-${crypto.randomUUID()}`;
|
|
36
|
+
const secondId = `queue-${crypto.randomUUID()}`;
|
|
37
|
+
await insertThreadQueueItem({
|
|
38
|
+
queueId: firstId,
|
|
39
|
+
threadId,
|
|
40
|
+
payloadJson: JSON.stringify({ prompt: 'first', userId: '1', username: 'tommy' }),
|
|
41
|
+
});
|
|
42
|
+
await insertThreadQueueItem({
|
|
43
|
+
queueId: secondId,
|
|
44
|
+
threadId,
|
|
45
|
+
payloadJson: JSON.stringify({ prompt: 'second', userId: '1', username: 'tommy' }),
|
|
46
|
+
});
|
|
47
|
+
const rows = await listThreadQueueItems(threadId);
|
|
48
|
+
expect(rows.map((row) => JSON.parse(row.payload_json).prompt)).toEqual([
|
|
49
|
+
'first',
|
|
50
|
+
'second',
|
|
51
|
+
]);
|
|
52
|
+
await updateThreadQueueItemPayload({
|
|
53
|
+
queueId: firstId,
|
|
54
|
+
payloadJson: JSON.stringify({ prompt: 'first-edited', userId: '1', username: 'tommy' }),
|
|
55
|
+
});
|
|
56
|
+
await deleteThreadQueueItem(secondId);
|
|
57
|
+
const after = await listAllThreadQueueItems();
|
|
58
|
+
const remaining = after.filter((row) => row.thread_id === threadId);
|
|
59
|
+
expect(remaining).toHaveLength(1);
|
|
60
|
+
expect(JSON.parse(remaining[0].payload_json).prompt).toBe('first-edited');
|
|
61
|
+
await db.delete(schema.thread_queue_items).where(orm.eq(schema.thread_queue_items.thread_id, threadId));
|
|
62
|
+
});
|
|
63
|
+
test('rebuilds thread_queue_items that still use queue_id as the primary key', async () => {
|
|
64
|
+
await closeDb();
|
|
65
|
+
const previousDbUrl = process.env['KIMAKI_DB_URL'];
|
|
66
|
+
const dbPath = path.join(process.cwd(), `tmp/test-db-legacy-queue-${crypto.randomUUID().slice(0, 8)}.db`);
|
|
67
|
+
try {
|
|
68
|
+
const client = createClient({ url: `file:${dbPath}` });
|
|
69
|
+
await client.execute(`
|
|
70
|
+
CREATE TABLE thread_queue_items (
|
|
71
|
+
queue_id text PRIMARY KEY,
|
|
72
|
+
thread_id text NOT NULL,
|
|
73
|
+
payload_json text NOT NULL,
|
|
74
|
+
created_at datetime DEFAULT CURRENT_TIMESTAMP
|
|
75
|
+
)
|
|
76
|
+
`);
|
|
77
|
+
await client.execute(`
|
|
78
|
+
CREATE INDEX thread_queue_items_thread_id_created_at_queue_id_idx
|
|
79
|
+
ON thread_queue_items (thread_id, created_at, queue_id)
|
|
80
|
+
`);
|
|
81
|
+
await client.execute(`
|
|
82
|
+
INSERT INTO thread_queue_items (queue_id, thread_id, payload_json, created_at)
|
|
83
|
+
VALUES
|
|
84
|
+
('queue-second', 'thr-legacy-queue', '{"prompt":"second"}', '2026-09-17 10:00:02'),
|
|
85
|
+
('queue-first', 'thr-legacy-queue', '{"prompt":"first"}', '2026-09-17 10:00:01')
|
|
86
|
+
`);
|
|
87
|
+
client.close();
|
|
88
|
+
process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
|
|
89
|
+
await getDb();
|
|
90
|
+
const rows = await listThreadQueueItems('thr-legacy-queue');
|
|
91
|
+
expect(rows.map((row) => JSON.parse(row.payload_json).prompt)).toEqual([
|
|
92
|
+
'first',
|
|
93
|
+
'second',
|
|
94
|
+
]);
|
|
95
|
+
expect(rows.map((row) => row.id)).toEqual([1, 2]);
|
|
96
|
+
await insertThreadQueueItem({
|
|
97
|
+
queueId: 'queue-third',
|
|
98
|
+
threadId: 'thr-legacy-queue',
|
|
99
|
+
payloadJson: JSON.stringify({ prompt: 'third' }),
|
|
100
|
+
});
|
|
101
|
+
const after = await listThreadQueueItems('thr-legacy-queue');
|
|
102
|
+
expect(after.map((row) => JSON.parse(row.payload_json).prompt)).toEqual([
|
|
103
|
+
'first',
|
|
104
|
+
'second',
|
|
105
|
+
'third',
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await closeDb();
|
|
110
|
+
if (previousDbUrl === undefined) {
|
|
111
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
process.env['KIMAKI_DB_URL'] = previousDbUrl;
|
|
115
|
+
}
|
|
116
|
+
for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
117
|
+
try {
|
|
118
|
+
fs.unlinkSync(file);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Test cleanup best effort.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
30
125
|
});
|
|
31
126
|
test('removes part_messages rows whose thread_sessions parent is gone', async () => {
|
|
32
127
|
await closeDb();
|
|
@@ -97,11 +97,12 @@ for (const setup of ['legacy mapping', 'explicit onboarding']) {
|
|
|
97
97
|
const guild = client.guilds.cache.get(trustedGuildId);
|
|
98
98
|
if (setup === 'legacy mapping') {
|
|
99
99
|
await setChannelDirectory({ channelId: projectChannelId, directory, channelType: 'text' });
|
|
100
|
+
expect((await findChannelsByDirectory({}))[0]?.guild_id).toBeNull();
|
|
100
101
|
}
|
|
101
102
|
else {
|
|
102
103
|
await createProjectChannels({ guild, projectDirectory: path.join(directory, 'project'), analyticsSource: 'onboarding' });
|
|
104
|
+
expect((await findChannelsByDirectory({}))[0]?.guild_id).toBe(trustedGuildId);
|
|
103
105
|
}
|
|
104
|
-
expect((await findChannelsByDirectory({}))[0]?.guild_id).toBeNull();
|
|
105
106
|
// Fetching actual guild channels must work even when startup cache is empty.
|
|
106
107
|
guild.channels.cache.clear();
|
|
107
108
|
const created = await ensureDefaultChannelsWithWelcome({
|
package/dist/discord-bot.js
CHANGED
|
@@ -30,7 +30,7 @@ import { cancelHtmlActionsForThread } from './html-actions.js';
|
|
|
30
30
|
import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
|
|
31
31
|
import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
|
|
32
32
|
import {} from './session-handler/model-utils.js';
|
|
33
|
-
import { getRuntime, getOrCreateRuntime, disposeRuntime, reserveThreadIngress, runInThreadIngressSlot, } from './session-handler/thread-session-runtime.js';
|
|
33
|
+
import { getRuntime, getOrCreateRuntime, disposeRuntime, restorePersistedLocalQueues, reserveThreadIngress, runInThreadIngressSlot, } from './session-handler/thread-session-runtime.js';
|
|
34
34
|
import { runShellCommand } from './commands/run-command.js';
|
|
35
35
|
import { registerInteractionHandler } from './interaction-handler.js';
|
|
36
36
|
import { getDiscordRestApiUrl } from './discord-urls.js';
|
|
@@ -214,6 +214,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
214
214
|
registerInteractionHandler({ discordClient: c, appId: currentAppId });
|
|
215
215
|
registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
|
|
216
216
|
startExternalOpencodeSessionSync({ discordClient: c });
|
|
217
|
+
await restorePersistedLocalQueues({
|
|
218
|
+
discordClient: c,
|
|
219
|
+
appId: currentAppId,
|
|
220
|
+
}).catch((error) => {
|
|
221
|
+
discordLogger.warn(`Failed to restore persisted local queues: ${error instanceof Error ? error.stack : String(error)}`);
|
|
222
|
+
});
|
|
217
223
|
// Channel logging is informational only; do it in background so startup stays responsive.
|
|
218
224
|
void (async () => {
|
|
219
225
|
for (const guild of c.guilds.cache.values()) {
|
|
@@ -896,7 +902,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
896
902
|
const { prompt, forceQueue } = extractQueueSuffix(resolveMentions(message));
|
|
897
903
|
// If the edit removed the queue suffix, remove the item from the queue.
|
|
898
904
|
// If the suffix is still present, update the prompt.
|
|
899
|
-
const result = runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
|
|
905
|
+
const result = await runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
|
|
900
906
|
if (result.found && channel.isThread()) {
|
|
901
907
|
const displayName = message.member?.displayName ?? message.author.displayName;
|
|
902
908
|
if (result.removed) {
|
|
@@ -924,7 +930,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
924
930
|
const runtime = getRuntime(channel.id);
|
|
925
931
|
if (!runtime)
|
|
926
932
|
return;
|
|
927
|
-
const removed = runtime.removeQueuedMessage(message.id);
|
|
933
|
+
const removed = await runtime.removeQueuedMessage(message.id);
|
|
928
934
|
if (!removed)
|
|
929
935
|
return;
|
|
930
936
|
discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
|
|
@@ -335,7 +335,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
335
335
|
.toJSON(),
|
|
336
336
|
new SlashCommandBuilder()
|
|
337
337
|
.setName('session-id')
|
|
338
|
-
.setDescription(truncateCommandDescription('Show current session ID and opencode attach command for this thread'))
|
|
338
|
+
.setDescription(truncateCommandDescription('Show current session ID, Discord thread ID, and opencode attach command for this thread'))
|
|
339
339
|
.setDMPermission(false)
|
|
340
340
|
.toJSON(),
|
|
341
341
|
new SlashCommandBuilder()
|