kimaki 0.17.1 → 0.18.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/bin.js +5 -2
- package/dist/cache-drift-plugin.js +176 -0
- package/dist/cli-commands/bot.js +26 -0
- package/dist/cli-commands/session.js +4 -4
- package/dist/cli-runner.js +70 -18
- package/dist/cli-runner.test.js +23 -0
- package/dist/cli.js +5 -0
- package/dist/commands/ask-question.js +13 -3
- package/dist/commands/ask-question.test.js +18 -2
- package/dist/commands/merge-worktree.js +11 -10
- package/dist/commands/new-worktree.js +55 -25
- package/dist/commands/worktrees.js +73 -67
- package/dist/database.js +54 -0
- package/dist/db.js +13 -0
- package/dist/db.test.js +12 -11
- package/dist/discord-bot.js +59 -14
- package/dist/discord-utils.js +9 -8
- package/dist/git-worktree-core.js +296 -0
- package/dist/image-utils.js +1 -1
- package/dist/interaction-handler.js +66 -0
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kimaki-workspace-adaptor.js +95 -0
- package/dist/opencode.js +1 -0
- package/dist/schema.js +16 -0
- package/dist/session-handler/thread-session-runtime.js +15 -15
- package/dist/store.js +1 -0
- package/dist/system-message.js +12 -0
- package/dist/system-message.test.js +12 -0
- package/dist/voice-handler.js +8 -0
- package/dist/worktree-lifecycle.e2e.test.js +187 -21
- package/dist/worktrees.js +13 -61
- package/dist/worktrees.test.js +17 -0
- package/package.json +4 -4
- package/skills/playwriter/SKILL.md +1 -1
- package/skills/sigillo/SKILL.md +2 -2
- package/src/bin.ts +8 -2
- package/src/cache-drift-plugin.ts +239 -0
- package/src/cli-commands/bot.ts +34 -0
- package/src/cli-commands/session.ts +4 -4
- package/src/cli-runner.test.ts +27 -0
- package/src/cli-runner.ts +83 -20
- package/src/cli.ts +8 -0
- package/src/commands/ask-question.test.ts +20 -1
- package/src/commands/ask-question.ts +25 -7
- package/src/commands/merge-worktree.ts +12 -12
- package/src/commands/new-worktree.ts +66 -27
- package/src/commands/worktrees.ts +84 -86
- package/src/database.ts +90 -0
- package/src/db.test.ts +12 -11
- package/src/db.ts +14 -0
- package/src/discord-bot.ts +76 -18
- package/src/discord-utils.ts +9 -8
- package/src/git-worktree-core.ts +389 -0
- package/src/image-utils.ts +5 -4
- package/src/interaction-handler.ts +77 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/kimaki-workspace-adaptor.ts +115 -0
- package/src/opencode.ts +2 -1
- package/src/schema.sql +13 -0
- package/src/schema.ts +18 -0
- package/src/session-handler/thread-session-runtime.ts +15 -15
- package/src/store.ts +8 -0
- package/src/system-message.test.ts +12 -0
- package/src/system-message.ts +12 -0
- package/src/voice-handler.ts +11 -0
- package/src/worktree-lifecycle.e2e.test.ts +229 -24
- package/src/worktrees.test.ts +19 -0
- package/src/worktrees.ts +13 -75
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
import { ChannelType, REST, } from 'discord.js';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import { OpenCodeSdkError } from '../errors.js';
|
|
7
|
-
import {
|
|
7
|
+
import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession, setThreadSession, } from '../database.js';
|
|
8
8
|
import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
|
|
9
9
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
10
|
import { notifyError } from '../sentry.js';
|
|
11
|
-
import {
|
|
11
|
+
import { execAsync, listBranchesByLastCommit, validateBranchRef, } from '../worktrees.js';
|
|
12
12
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
13
13
|
import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
|
|
14
14
|
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
@@ -139,10 +139,40 @@ async function getProjectDirectoryFromChannel(channel) {
|
|
|
139
139
|
return channelConfig.directory;
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
142
|
+
* Try creating a worktree via the OpenCode workspace SDK.
|
|
143
|
+
* Returns the workspace directory on success, or an Error if the workspace
|
|
144
|
+
* feature is not available or the creation fails. Callers fall back to the
|
|
145
|
+
* direct git path on Error.
|
|
146
|
+
*/
|
|
147
|
+
async function tryWorkspaceCreate({ threadId, worktreeName, projectDirectory, baseBranch, }) {
|
|
148
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
149
|
+
if (getClient instanceof Error)
|
|
150
|
+
return getClient;
|
|
151
|
+
const client = getClient();
|
|
152
|
+
const response = await client.experimental.workspace.create({
|
|
153
|
+
directory: projectDirectory,
|
|
154
|
+
type: 'kimaki-worktree',
|
|
155
|
+
branch: worktreeName,
|
|
156
|
+
extra: baseBranch ? { baseBranch } : null,
|
|
157
|
+
}).catch((e) => new OpenCodeSdkError({ operation: 'workspace.create', cause: e }));
|
|
158
|
+
if (response instanceof Error)
|
|
159
|
+
return response;
|
|
160
|
+
if (response.error) {
|
|
161
|
+
return new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`);
|
|
162
|
+
}
|
|
163
|
+
const workspace = response.data;
|
|
164
|
+
if (!workspace?.directory) {
|
|
165
|
+
return new Error('Workspace SDK returned no directory');
|
|
166
|
+
}
|
|
167
|
+
return { directory: workspace.directory, workspaceId: workspace.id };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Create worktree via the OpenCode workspace SDK and update the status message.
|
|
171
|
+
* Handles the full lifecycle: pending DB entry, SDK creation, DB ready/error,
|
|
144
172
|
* tree emoji reaction, and editing the status message.
|
|
145
173
|
*
|
|
174
|
+
* Uses thread_workspaces table exclusively (no legacy thread_worktrees).
|
|
175
|
+
*
|
|
146
176
|
* starterMessage is optional — if omitted, status edits are skipped (creation
|
|
147
177
|
* still proceeds). This keeps worktree creation independent of Discord message
|
|
148
178
|
* delivery, so a transient send failure never silently skips the worktree.
|
|
@@ -163,33 +193,32 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
|
|
|
163
193
|
})
|
|
164
194
|
.catch(() => { });
|
|
165
195
|
};
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
await
|
|
196
|
+
// Write pending row BEFORE workspace creation so restarts/follow-up
|
|
197
|
+
// messages can see the in-progress state.
|
|
198
|
+
await createPendingWorkspace({
|
|
169
199
|
threadId: thread.id,
|
|
170
|
-
|
|
200
|
+
workspaceType: 'kimaki-worktree',
|
|
201
|
+
workspaceName: worktreeName,
|
|
171
202
|
projectDirectory,
|
|
172
203
|
});
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
204
|
+
const workspaceResult = await tryWorkspaceCreate({
|
|
205
|
+
threadId: thread.id,
|
|
206
|
+
worktreeName,
|
|
207
|
+
projectDirectory,
|
|
176
208
|
baseBranch,
|
|
177
|
-
onProgress: (phase) => {
|
|
178
|
-
editStatus(`🌳 **Worktree: ${worktreeName}**\n${phase}`);
|
|
179
|
-
},
|
|
180
209
|
});
|
|
181
|
-
if (
|
|
182
|
-
const errorMsg =
|
|
183
|
-
logger.error('[WORKTREE]
|
|
184
|
-
await
|
|
210
|
+
if (workspaceResult instanceof Error) {
|
|
211
|
+
const errorMsg = workspaceResult.message;
|
|
212
|
+
logger.error('[WORKTREE] Workspace creation failed:', workspaceResult);
|
|
213
|
+
await setWorkspaceError({ threadId: thread.id, errorMessage: errorMsg });
|
|
185
214
|
editStatus(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`);
|
|
186
215
|
await editChain;
|
|
187
|
-
return
|
|
216
|
+
return workspaceResult;
|
|
188
217
|
}
|
|
189
|
-
|
|
190
|
-
await setWorktreeReady({
|
|
218
|
+
await setWorkspaceReady({
|
|
191
219
|
threadId: thread.id,
|
|
192
|
-
|
|
220
|
+
workspaceId: workspaceResult.workspaceId,
|
|
221
|
+
workspaceDirectory: workspaceResult.directory,
|
|
193
222
|
});
|
|
194
223
|
void reactToThread({
|
|
195
224
|
rest,
|
|
@@ -198,10 +227,11 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
|
|
|
198
227
|
emoji: '🌳',
|
|
199
228
|
}).catch(() => { });
|
|
200
229
|
editStatus(`🌳 **Worktree: ${worktreeName}**\n` +
|
|
201
|
-
`📁 \`${
|
|
202
|
-
`🌿 Branch: \`${
|
|
230
|
+
`📁 \`${workspaceResult.directory}\`\n` +
|
|
231
|
+
`🌿 Branch: \`${worktreeName}\``);
|
|
203
232
|
await editChain;
|
|
204
|
-
|
|
233
|
+
logger.log(`[WORKTREE] Created via workspace SDK: ${workspaceResult.directory}`);
|
|
234
|
+
return workspaceResult.directory;
|
|
205
235
|
})().catch((e) => {
|
|
206
236
|
logger.error('[WORKTREE] Unexpected error in createWorktreeInBackground:', e);
|
|
207
237
|
return new Error(`Worktree creation failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
// Renders a markdown table that the CV2 pipeline auto-formats for Discord,
|
|
6
6
|
// including HTML-backed action buttons for deletable worktrees.
|
|
7
7
|
import { ButtonInteraction, ChatInputCommandInteraction, ChannelType, ComponentType, MessageFlags, } from 'discord.js';
|
|
8
|
-
import {
|
|
8
|
+
import { deleteThreadWorkspace, } from '../database.js';
|
|
9
9
|
import { getDb } from '../db.js';
|
|
10
10
|
import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js';
|
|
11
11
|
import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
|
|
12
12
|
import * as errore from 'errore';
|
|
13
13
|
import crypto from 'node:crypto';
|
|
14
|
-
import { GitCommandError } from '../errors.js';
|
|
14
|
+
import { GitCommandError, OpenCodeSdkError } from '../errors.js';
|
|
15
15
|
import { resolveWorkingDirectory } from '../discord-utils.js';
|
|
16
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
16
17
|
import { deleteWorktree, git, getDefaultBranch, listGitWorktrees, } from '../worktrees.js';
|
|
17
18
|
import path from 'node:path';
|
|
18
19
|
// Extracts the git stderr from a deleteWorktree error via errore.findCause.
|
|
@@ -144,11 +145,7 @@ function buildActionCell({ row, gitStatus, }) {
|
|
|
144
145
|
if (!canDeleteWorktree({ row, gitStatus })) {
|
|
145
146
|
return '-';
|
|
146
147
|
}
|
|
147
|
-
|
|
148
|
-
buttonId: `del-wt-${worktreeButtonKey(row.directory)}`,
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
function buildDeleteButtonHtml({ buttonId, }) {
|
|
148
|
+
const buttonId = `del-wt-${worktreeButtonKey(row.directory)}`;
|
|
152
149
|
return `<button id="${buttonId}" variant="secondary">Delete</button>`;
|
|
153
150
|
}
|
|
154
151
|
function canDeleteWorktree({ row, gitStatus, }) {
|
|
@@ -198,21 +195,24 @@ async function resolveGitStatuses({ rows, projectDirectory, timeout, }) {
|
|
|
198
195
|
// in the git list (pending/error) are appended at the end.
|
|
199
196
|
async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
|
|
200
197
|
const db = await getDb();
|
|
201
|
-
const
|
|
198
|
+
const dbWorkspaces = await db.query.thread_workspaces.findMany({
|
|
202
199
|
where: { project_directory: projectDirectory },
|
|
203
200
|
});
|
|
204
|
-
|
|
201
|
+
const toDate = (v) => {
|
|
202
|
+
if (!v)
|
|
203
|
+
return null;
|
|
204
|
+
return v instanceof Date ? v : new Date(v);
|
|
205
|
+
};
|
|
206
|
+
// Index by directory for fast lookup
|
|
205
207
|
const dbByDirectory = new Map();
|
|
206
|
-
for (const
|
|
207
|
-
if (
|
|
208
|
-
dbByDirectory.set(
|
|
208
|
+
for (const ws of dbWorkspaces) {
|
|
209
|
+
if (ws.workspace_directory) {
|
|
210
|
+
dbByDirectory.set(ws.workspace_directory, ws);
|
|
209
211
|
}
|
|
210
212
|
}
|
|
211
213
|
// Track which DB rows got matched so we can append unmatched ones
|
|
212
214
|
const matchedDbThreadIds = new Set();
|
|
213
215
|
// Build rows from git worktrees (the source of truth for on-disk state).
|
|
214
|
-
// Use real DB status when available — a git-visible worktree whose DB row
|
|
215
|
-
// is still 'pending' means setup hasn't finished (race window).
|
|
216
216
|
const gitRows = gitWorktrees.map((gw) => {
|
|
217
217
|
const dbMatch = dbByDirectory.get(gw.directory);
|
|
218
218
|
if (dbMatch) {
|
|
@@ -224,15 +224,12 @@ async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
|
|
|
224
224
|
});
|
|
225
225
|
const name = gw.branch ?? path.basename(gw.directory);
|
|
226
226
|
const dbStatus = (() => {
|
|
227
|
-
if (!dbMatch)
|
|
227
|
+
if (!dbMatch)
|
|
228
228
|
return 'ready';
|
|
229
|
-
|
|
230
|
-
if (dbMatch.status === 'error') {
|
|
229
|
+
if (dbMatch.status === 'error')
|
|
231
230
|
return 'error';
|
|
232
|
-
|
|
233
|
-
if (dbMatch.status === 'pending') {
|
|
231
|
+
if (dbMatch.status === 'pending')
|
|
234
232
|
return 'pending';
|
|
235
|
-
}
|
|
236
233
|
return 'ready';
|
|
237
234
|
})();
|
|
238
235
|
return {
|
|
@@ -240,48 +237,33 @@ async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
|
|
|
240
237
|
branch: gw.branch,
|
|
241
238
|
name,
|
|
242
239
|
threadId: dbMatch?.thread_id ?? null,
|
|
243
|
-
guildId: null,
|
|
244
|
-
createdAt: dbMatch?.created_at
|
|
240
|
+
guildId: null,
|
|
241
|
+
createdAt: toDate(dbMatch?.created_at),
|
|
245
242
|
source,
|
|
243
|
+
workspaceId: dbMatch?.workspace_id ?? null,
|
|
246
244
|
dbStatus,
|
|
247
245
|
locked: gw.locked,
|
|
248
246
|
prunable: gw.prunable,
|
|
249
247
|
};
|
|
250
248
|
});
|
|
251
|
-
// Append DB-only
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return {
|
|
268
|
-
directory: dbWt.worktree_directory ?? dbWt.project_directory,
|
|
269
|
-
branch: null,
|
|
270
|
-
name: dbWt.worktree_name,
|
|
271
|
-
threadId: dbWt.thread_id,
|
|
272
|
-
guildId: null,
|
|
273
|
-
createdAt: dbWt.created_at,
|
|
274
|
-
source: 'kimaki',
|
|
275
|
-
dbStatus,
|
|
276
|
-
locked: false,
|
|
277
|
-
prunable: false,
|
|
278
|
-
};
|
|
279
|
-
});
|
|
249
|
+
// Append DB-only workspaces (pending/error/stale — not visible to git).
|
|
250
|
+
const dbOnlyRows = dbWorkspaces
|
|
251
|
+
.filter((ws) => !matchedDbThreadIds.has(ws.thread_id))
|
|
252
|
+
.map((ws) => ({
|
|
253
|
+
directory: ws.workspace_directory ?? ws.project_directory,
|
|
254
|
+
branch: null,
|
|
255
|
+
name: ws.workspace_name,
|
|
256
|
+
threadId: ws.thread_id,
|
|
257
|
+
guildId: null,
|
|
258
|
+
createdAt: toDate(ws.created_at),
|
|
259
|
+
source: 'kimaki',
|
|
260
|
+
workspaceId: ws.workspace_id,
|
|
261
|
+
dbStatus: ws.status === 'error' ? 'error' : ws.status === 'pending' ? 'pending' : 'ready',
|
|
262
|
+
locked: false,
|
|
263
|
+
prunable: false,
|
|
264
|
+
}));
|
|
280
265
|
return [...gitRows, ...dbOnlyRows];
|
|
281
266
|
}
|
|
282
|
-
function getWorktreesActionOwnerKey({ userId, channelId, }) {
|
|
283
|
-
return `worktrees:${userId}:${channelId}`;
|
|
284
|
-
}
|
|
285
267
|
function isProjectChannel(channel) {
|
|
286
268
|
if (!channel) {
|
|
287
269
|
return false;
|
|
@@ -293,8 +275,19 @@ function isProjectChannel(channel) {
|
|
|
293
275
|
ChannelType.AnnouncementThread,
|
|
294
276
|
].includes(channel.type);
|
|
295
277
|
}
|
|
278
|
+
function resolveWorktreesWorkingDirectory(channel) {
|
|
279
|
+
switch (channel.type) {
|
|
280
|
+
case ChannelType.GuildText:
|
|
281
|
+
case ChannelType.PublicThread:
|
|
282
|
+
case ChannelType.PrivateThread:
|
|
283
|
+
case ChannelType.AnnouncementThread:
|
|
284
|
+
return resolveWorkingDirectory({ channel });
|
|
285
|
+
default:
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
296
289
|
async function renderWorktreesReply({ guildId, userId, channelId, projectDirectory, notice, editReply, }) {
|
|
297
|
-
const ownerKey =
|
|
290
|
+
const ownerKey = `worktrees:${userId}:${channelId}`;
|
|
298
291
|
cancelHtmlActionsForOwner(ownerKey);
|
|
299
292
|
const gitWorktrees = await listGitWorktrees({
|
|
300
293
|
projectDirectory,
|
|
@@ -405,14 +398,17 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
|
|
|
405
398
|
});
|
|
406
399
|
return;
|
|
407
400
|
}
|
|
408
|
-
//
|
|
409
|
-
//
|
|
401
|
+
// SDK-created workspaces must be removed through OpenCode so its workspace
|
|
402
|
+
// table stays in sync. Legacy/manual worktrees have no workspace_id, so they
|
|
403
|
+
// still use the direct git cleanup path.
|
|
410
404
|
const displayName = row.branch ?? row.name;
|
|
411
|
-
const deleteResult =
|
|
412
|
-
projectDirectory,
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
405
|
+
const deleteResult = row.workspaceId
|
|
406
|
+
? await deleteWorkspace({ projectDirectory, workspaceId: row.workspaceId })
|
|
407
|
+
: await deleteWorktree({
|
|
408
|
+
projectDirectory,
|
|
409
|
+
worktreeDirectory: row.directory,
|
|
410
|
+
worktreeName: row.branch ?? '',
|
|
411
|
+
});
|
|
416
412
|
if (deleteResult instanceof Error) {
|
|
417
413
|
const gitStderr = extractGitStderr(deleteResult);
|
|
418
414
|
const detail = gitStderr
|
|
@@ -428,9 +424,8 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
|
|
|
428
424
|
});
|
|
429
425
|
return;
|
|
430
426
|
}
|
|
431
|
-
// Clean up DB row if this was a kimaki-tracked worktree
|
|
432
427
|
if (row.threadId) {
|
|
433
|
-
await
|
|
428
|
+
await deleteThreadWorkspace(row.threadId);
|
|
434
429
|
}
|
|
435
430
|
await renderWorktreesReply({
|
|
436
431
|
guildId,
|
|
@@ -443,6 +438,19 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
|
|
|
443
438
|
},
|
|
444
439
|
});
|
|
445
440
|
}
|
|
441
|
+
async function deleteWorkspace({ projectDirectory, workspaceId, }) {
|
|
442
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
443
|
+
if (getClient instanceof Error)
|
|
444
|
+
return getClient;
|
|
445
|
+
const response = await getClient().experimental.workspace.remove({
|
|
446
|
+
id: workspaceId,
|
|
447
|
+
directory: projectDirectory,
|
|
448
|
+
}).catch((e) => new OpenCodeSdkError({ operation: 'workspace.remove', cause: e }));
|
|
449
|
+
if (response instanceof Error)
|
|
450
|
+
return response;
|
|
451
|
+
if (response.error)
|
|
452
|
+
return new Error(`Workspace removal failed: ${JSON.stringify(response.error)}`);
|
|
453
|
+
}
|
|
446
454
|
export async function handleWorktreesCommand({ command, }) {
|
|
447
455
|
const channel = command.channel;
|
|
448
456
|
const guildId = command.guildId;
|
|
@@ -460,9 +468,7 @@ export async function handleWorktreesCommand({ command, }) {
|
|
|
460
468
|
});
|
|
461
469
|
return;
|
|
462
470
|
}
|
|
463
|
-
const resolved = await
|
|
464
|
-
channel: channel,
|
|
465
|
-
});
|
|
471
|
+
const resolved = await resolveWorktreesWorkingDirectory(channel);
|
|
466
472
|
if (!resolved) {
|
|
467
473
|
await command.reply({
|
|
468
474
|
content: 'Could not determine the project folder for this channel.',
|
package/dist/database.js
CHANGED
|
@@ -276,6 +276,60 @@ export async function deleteThreadWorktree(threadId) {
|
|
|
276
276
|
const db = await getDb();
|
|
277
277
|
await db.delete(schema.thread_worktrees).where(orm.eq(schema.thread_worktrees.thread_id, threadId));
|
|
278
278
|
}
|
|
279
|
+
export async function createPendingWorkspace({ threadId, workspaceType, workspaceName, projectDirectory, }) {
|
|
280
|
+
const db = await getDb();
|
|
281
|
+
await db.batch([
|
|
282
|
+
db.insert(schema.thread_sessions)
|
|
283
|
+
.values({ thread_id: threadId, session_id: '' })
|
|
284
|
+
.onConflictDoNothing({ target: schema.thread_sessions.thread_id }),
|
|
285
|
+
db.insert(schema.thread_workspaces)
|
|
286
|
+
.values({
|
|
287
|
+
thread_id: threadId,
|
|
288
|
+
workspace_type: workspaceType,
|
|
289
|
+
workspace_name: workspaceName,
|
|
290
|
+
project_directory: projectDirectory,
|
|
291
|
+
status: 'pending',
|
|
292
|
+
})
|
|
293
|
+
.onConflictDoUpdate({
|
|
294
|
+
target: schema.thread_workspaces.thread_id,
|
|
295
|
+
set: {
|
|
296
|
+
workspace_type: workspaceType,
|
|
297
|
+
workspace_name: workspaceName,
|
|
298
|
+
project_directory: projectDirectory,
|
|
299
|
+
status: 'pending',
|
|
300
|
+
workspace_id: null,
|
|
301
|
+
workspace_directory: null,
|
|
302
|
+
error_message: null,
|
|
303
|
+
},
|
|
304
|
+
}),
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
307
|
+
export async function setWorkspaceReady({ threadId, workspaceId, workspaceDirectory, }) {
|
|
308
|
+
const db = await getDb();
|
|
309
|
+
await db.update(schema.thread_workspaces)
|
|
310
|
+
.set({
|
|
311
|
+
workspace_directory: workspaceDirectory,
|
|
312
|
+
workspace_id: workspaceId ?? null,
|
|
313
|
+
status: 'ready',
|
|
314
|
+
})
|
|
315
|
+
.where(orm.eq(schema.thread_workspaces.thread_id, threadId));
|
|
316
|
+
}
|
|
317
|
+
export async function setWorkspaceError({ threadId, errorMessage, }) {
|
|
318
|
+
const db = await getDb();
|
|
319
|
+
await db.update(schema.thread_workspaces)
|
|
320
|
+
.set({ status: 'error', error_message: errorMessage })
|
|
321
|
+
.where(orm.eq(schema.thread_workspaces.thread_id, threadId));
|
|
322
|
+
}
|
|
323
|
+
export async function getThreadWorkspace(threadId) {
|
|
324
|
+
const db = await getDb();
|
|
325
|
+
return await db.query.thread_workspaces.findFirst({ where: { thread_id: threadId } }) ?? undefined;
|
|
326
|
+
}
|
|
327
|
+
export async function deleteThreadWorkspace(threadId) {
|
|
328
|
+
const db = await getDb();
|
|
329
|
+
await db.delete(schema.thread_workspaces).where(orm.eq(schema.thread_workspaces.thread_id, threadId));
|
|
330
|
+
}
|
|
331
|
+
/** Alias for getThreadWorkspace — used throughout the codebase. */
|
|
332
|
+
export const getThreadWorktreeOrWorkspace = getThreadWorkspace;
|
|
279
333
|
export async function getChannelVerbosity(channelId) {
|
|
280
334
|
const db = await getDb();
|
|
281
335
|
const row = await db.query.channel_verbosity.findFirst({ where: { channel_id: channelId } });
|
package/dist/db.js
CHANGED
|
@@ -142,6 +142,19 @@ async function migrateSchema({ db, client, }) {
|
|
|
142
142
|
for (const stmt of migrationStatements) {
|
|
143
143
|
await client.execute(stmt).catch(() => undefined);
|
|
144
144
|
}
|
|
145
|
+
// Migrate legacy thread_worktrees rows into thread_workspaces.
|
|
146
|
+
// Rows that already exist in thread_workspaces (by thread_id) are skipped.
|
|
147
|
+
await client.execute(`
|
|
148
|
+
INSERT OR IGNORE INTO thread_workspaces (
|
|
149
|
+
thread_id, workspace_type, status, error_message,
|
|
150
|
+
project_directory, workspace_directory, workspace_name, created_at
|
|
151
|
+
)
|
|
152
|
+
SELECT
|
|
153
|
+
thread_id, 'kimaki-worktree', status, error_message,
|
|
154
|
+
project_directory, worktree_directory, worktree_name, created_at
|
|
155
|
+
FROM thread_worktrees
|
|
156
|
+
WHERE thread_id NOT IN (SELECT thread_id FROM thread_workspaces)
|
|
157
|
+
`).catch(() => undefined);
|
|
145
158
|
const botRows = await db.query.bot_tokens.findMany({
|
|
146
159
|
columns: {
|
|
147
160
|
app_id: true,
|
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,
|
|
10
|
+
import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getSessionEventSnapshot, getSessionModel, setSessionModel, } from './database.js';
|
|
11
11
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
12
12
|
import { chooseLockPort } from './test-utils.js';
|
|
13
13
|
import { copyCurrentSessionModel } from './commands/model.js';
|
|
@@ -78,12 +78,13 @@ describe('getDb', () => {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
});
|
|
81
|
-
test('
|
|
81
|
+
test('createPendingWorkspace creates parent and child rows', async () => {
|
|
82
82
|
const db = await getDb();
|
|
83
|
-
const threadId = `test-
|
|
84
|
-
await
|
|
83
|
+
const threadId = `test-workspace-${Date.now()}`;
|
|
84
|
+
await createPendingWorkspace({
|
|
85
85
|
threadId,
|
|
86
|
-
|
|
86
|
+
workspaceType: 'kimaki-worktree',
|
|
87
|
+
workspaceName: 'regression-workspace',
|
|
87
88
|
projectDirectory: '/tmp/regression-project',
|
|
88
89
|
});
|
|
89
90
|
const session = await db.query.thread_sessions.findFirst({
|
|
@@ -91,14 +92,14 @@ describe('getDb', () => {
|
|
|
91
92
|
});
|
|
92
93
|
expect(session).toBeTruthy();
|
|
93
94
|
expect(session?.session_id).toBe('');
|
|
94
|
-
const
|
|
95
|
+
const workspace = await db.query.thread_workspaces.findFirst({
|
|
95
96
|
where: { thread_id: threadId },
|
|
96
97
|
});
|
|
97
|
-
expect(
|
|
98
|
-
expect(
|
|
99
|
-
expect(
|
|
100
|
-
expect(
|
|
101
|
-
await db.delete(schema.
|
|
98
|
+
expect(workspace).toBeTruthy();
|
|
99
|
+
expect(workspace?.workspace_name).toBe('regression-workspace');
|
|
100
|
+
expect(workspace?.project_directory).toBe('/tmp/regression-project');
|
|
101
|
+
expect(workspace?.status).toBe('pending');
|
|
102
|
+
await db.delete(schema.thread_workspaces).where(orm.eq(schema.thread_workspaces.thread_id, threadId));
|
|
102
103
|
await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
|
|
103
104
|
});
|
|
104
105
|
test('copyCurrentSessionModel snapshots source session model to forked session', async () => {
|
package/dist/discord-bot.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
// Core Discord bot module that handles message events and bot lifecycle.
|
|
2
2
|
// Bridges Discord messages to OpenCode sessions, manages voice connections,
|
|
3
3
|
// and orchestrates the main event loop for the Kimaki bot.
|
|
4
|
+
//
|
|
5
|
+
// Shutdown resilience: during self-restart (gateway reconnect limit, SIGUSR2),
|
|
6
|
+
// discord.js can still fire errors from pending async operations (DNS lookups,
|
|
7
|
+
// WebSocket frames) after the client is destroyed. The uncaughtException handler
|
|
8
|
+
// suppresses these when shuttingDown is already set, and we removeAllListeners()
|
|
9
|
+
// before destroying the client to prevent late events from becoming uncaught.
|
|
4
10
|
import { DiscordOperationError } from './errors.js';
|
|
5
|
-
import { initDatabase, closeDatabase,
|
|
11
|
+
import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
|
|
6
12
|
import { stopOpencodeServer, } from './opencode.js';
|
|
7
13
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
8
14
|
import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
|
|
@@ -359,6 +365,32 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
359
365
|
}
|
|
360
366
|
}
|
|
361
367
|
}
|
|
368
|
+
// Multi-machine routing: check channel ownership before permission
|
|
369
|
+
// checks so we don't send permission-denial replies in channels owned
|
|
370
|
+
// by another machine. Resolve the "owning" channel: for threads, check
|
|
371
|
+
// the parent channel; for text channels, check the channel itself.
|
|
372
|
+
if (!isCliInjectedPrompt && message.guild) {
|
|
373
|
+
const channel = message.channel;
|
|
374
|
+
let owningChannelId;
|
|
375
|
+
if ([
|
|
376
|
+
ChannelType.PublicThread,
|
|
377
|
+
ChannelType.PrivateThread,
|
|
378
|
+
ChannelType.AnnouncementThread,
|
|
379
|
+
].includes(channel.type)) {
|
|
380
|
+
const thread = channel;
|
|
381
|
+
owningChannelId = thread.parent?.id || thread.parentId || undefined;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
owningChannelId = channel.id;
|
|
385
|
+
}
|
|
386
|
+
if (owningChannelId) {
|
|
387
|
+
const channelConfig = await getChannelDirectory(owningChannelId);
|
|
388
|
+
if (!channelConfig) {
|
|
389
|
+
voiceLogger.log(`[IGNORED] Channel ${owningChannelId} has no project directory configured`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
362
394
|
if (!isCliInjectedPrompt && message.guild) {
|
|
363
395
|
const member = await resolveGuildMessageMember(message);
|
|
364
396
|
if (!member) {
|
|
@@ -392,7 +424,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
392
424
|
// thread itself (e.g. /new-worktree, /fork, kimaki send). This prevents
|
|
393
425
|
// the bot from hijacking user-created threads in project channels while
|
|
394
426
|
// still responding to bot-created threads that may not yet have a session
|
|
395
|
-
// row with a non-empty session_id (
|
|
427
|
+
// row with a non-empty session_id (createPendingWorkspace sets ''). (GitHub #84)
|
|
396
428
|
const hasExistingSession = await getThreadSession(thread.id);
|
|
397
429
|
const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
|
|
398
430
|
const botCreatedThread = discordClient.user && thread.ownerId === discordClient.user.id;
|
|
@@ -416,7 +448,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
416
448
|
// the preprocess chain (messages queue behind the worktree promise).
|
|
417
449
|
// After a bot restart the runtime is gone, so we must reject messages
|
|
418
450
|
// for pending worktrees to avoid running in the base directory.
|
|
419
|
-
|
|
451
|
+
// Check both thread_workspaces (new) and thread_worktrees (legacy)
|
|
452
|
+
const worktreeInfo = await getThreadWorktreeOrWorkspace(thread.id);
|
|
420
453
|
if (worktreeInfo) {
|
|
421
454
|
if (worktreeInfo.status === 'pending' && !getRuntime(thread.id)) {
|
|
422
455
|
await message.reply({
|
|
@@ -436,7 +469,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
436
469
|
// The worktree directory is passed via query.directory in prompt/command calls
|
|
437
470
|
if (worktreeInfo.project_directory) {
|
|
438
471
|
projectDirectory = worktreeInfo.project_directory;
|
|
439
|
-
discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.
|
|
472
|
+
discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.workspace_directory})`);
|
|
440
473
|
}
|
|
441
474
|
}
|
|
442
475
|
if (projectDirectory && !fs.existsSync(projectDirectory)) {
|
|
@@ -456,8 +489,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
456
489
|
const shellCmd = message.content.slice(1).trim();
|
|
457
490
|
if (shellCmd) {
|
|
458
491
|
const shellDir = worktreeInfo?.status === 'ready' &&
|
|
459
|
-
worktreeInfo.
|
|
460
|
-
? worktreeInfo.
|
|
492
|
+
worktreeInfo.workspace_directory
|
|
493
|
+
? worktreeInfo.workspace_directory
|
|
461
494
|
: projectDirectory;
|
|
462
495
|
const loadingReply = await message.reply({
|
|
463
496
|
content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
|
|
@@ -514,8 +547,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
514
547
|
}
|
|
515
548
|
const resolvedProjectDir = projectDirectory;
|
|
516
549
|
const sdkDir = worktreeInfo?.status === 'ready' &&
|
|
517
|
-
worktreeInfo.
|
|
518
|
-
? worktreeInfo.
|
|
550
|
+
worktreeInfo.workspace_directory
|
|
551
|
+
? worktreeInfo.workspace_directory
|
|
519
552
|
: resolvedProjectDir;
|
|
520
553
|
const runtime = getOrCreateRuntime({
|
|
521
554
|
threadId: thread.id,
|
|
@@ -895,7 +928,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
895
928
|
}
|
|
896
929
|
// --cwd: reuse an existing project subfolder or worktree directory. Revalidate at bot-time
|
|
897
930
|
// (CLI validated at send-time but the path could become stale).
|
|
898
|
-
// Only worktree directories are stored in
|
|
931
|
+
// Only worktree directories are stored in thread_workspaces. Project
|
|
899
932
|
// subfolders simply become the OpenCode session directory.
|
|
900
933
|
// --cwd: if it matches projectDirectory, ignore silently (already the default).
|
|
901
934
|
let cwdDirectory;
|
|
@@ -921,14 +954,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
921
954
|
const cwdWorktreeName = branchResult instanceof Error
|
|
922
955
|
? path.basename(cwdDirectory)
|
|
923
956
|
: branchResult;
|
|
924
|
-
await
|
|
957
|
+
await createPendingWorkspace({
|
|
925
958
|
threadId: thread.id,
|
|
926
|
-
|
|
959
|
+
workspaceType: 'kimaki-worktree',
|
|
960
|
+
workspaceName: cwdWorktreeName,
|
|
927
961
|
projectDirectory,
|
|
928
962
|
});
|
|
929
|
-
await
|
|
963
|
+
await setWorkspaceReady({
|
|
930
964
|
threadId: thread.id,
|
|
931
|
-
|
|
965
|
+
workspaceDirectory: cwdDirectory,
|
|
932
966
|
});
|
|
933
967
|
// React with tree emoji to mark as worktree thread
|
|
934
968
|
await reactToThread({
|
|
@@ -1048,7 +1082,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1048
1082
|
discordLogger.log('Already shutting down, ignoring duplicate signal');
|
|
1049
1083
|
return;
|
|
1050
1084
|
}
|
|
1051
|
-
;
|
|
1052
1085
|
global.shuttingDown = true;
|
|
1053
1086
|
try {
|
|
1054
1087
|
await stopRuntimeIdleSweeper();
|
|
@@ -1078,6 +1111,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1078
1111
|
discordLogger.log('Stopping hrana server...');
|
|
1079
1112
|
await stopHranaServer();
|
|
1080
1113
|
discordLogger.log('Destroying Discord client...');
|
|
1114
|
+
// Remove all listeners before destroy to prevent late-arriving shard
|
|
1115
|
+
// errors (from pending DNS lookups, WebSocket frames) from becoming
|
|
1116
|
+
// uncaught exceptions after the client's internal handlers are torn down.
|
|
1117
|
+
discordClient.removeAllListeners();
|
|
1081
1118
|
void discordClient.destroy();
|
|
1082
1119
|
discordLogger.log('Cleanup complete.');
|
|
1083
1120
|
if (!skipExit) {
|
|
@@ -1150,6 +1187,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1150
1187
|
void selfRestart('SIGUSR2');
|
|
1151
1188
|
});
|
|
1152
1189
|
process.on('uncaughtException', (error) => {
|
|
1190
|
+
// During self-restart or shutdown, discord.js can still fire errors from
|
|
1191
|
+
// pending async operations (DNS lookups, WebSocket frames) after the client
|
|
1192
|
+
// is destroyed. These are expected and must not interfere with the restart
|
|
1193
|
+
// flow — let the existing selfRestart/handleShutdown finish cleanly.
|
|
1194
|
+
if (selfRestarting || global.shuttingDown) {
|
|
1195
|
+
discordLogger.log('Ignoring uncaught exception during shutdown:', error?.message || String(error));
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1153
1198
|
discordLogger.error('Uncaught exception:', formatErrorWithStack(error));
|
|
1154
1199
|
notifyError(error, 'Uncaught exception in bot process');
|
|
1155
1200
|
void handleShutdown('uncaughtException', { skipExit: true }).catch((shutdownError) => {
|