kimaki 0.17.0 → 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/channel-management.js +6 -8
- package/dist/cli-commands/bot.js +26 -0
- package/dist/cli-commands/session.js +4 -4
- package/dist/cli-runner.js +87 -22
- 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/last-sessions.js +3 -2
- package/dist/commands/merge-worktree.js +11 -10
- package/dist/commands/new-worktree.js +55 -25
- package/dist/commands/worktrees.js +92 -70
- 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/format-tables.js +138 -1
- package/dist/format-tables.test.js +224 -1
- package/dist/git-worktree-core.js +296 -0
- package/dist/image-utils.js +1 -1
- package/dist/interaction-handler.js +92 -7
- 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 +14 -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 +3 -3
- 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/channel-management.ts +6 -8
- 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 +102 -26
- 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/last-sessions.ts +4 -2
- package/src/commands/merge-worktree.ts +12 -12
- package/src/commands/new-worktree.ts +66 -27
- package/src/commands/worktrees.ts +104 -89
- 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/format-tables.test.ts +246 -0
- package/src/format-tables.ts +176 -1
- package/src/git-worktree-core.ts +389 -0
- package/src/image-utils.ts +5 -4
- package/src/interaction-handler.ts +102 -9
- 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 +21 -0
- package/src/worktree-lifecycle.e2e.test.ts +229 -24
- package/src/worktrees.test.ts +19 -0
- package/src/worktrees.ts +13 -75
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Preserves all commits (no squash). On rebase conflicts, asks the AI model
|
|
4
4
|
// in the thread to resolve them.
|
|
5
5
|
import {} from 'discord.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getThreadWorktreeOrWorkspace, getThreadSession, getChannelDirectory, } from '../database.js';
|
|
7
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
8
|
import { notifyError } from '../sentry.js';
|
|
9
9
|
import { mergeWorktree, listBranchesByLastCommit, validateBranchRef } from '../worktrees.js';
|
|
@@ -63,20 +63,21 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
|
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
const thread = channel;
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// Check both thread_workspaces (new) and thread_worktrees (legacy)
|
|
67
|
+
const info = await getThreadWorktreeOrWorkspace(thread.id);
|
|
68
|
+
if (!info) {
|
|
68
69
|
await command.editReply('This thread is not associated with a worktree');
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
|
-
if (
|
|
72
|
-
await command.editReply(`Worktree is not ready (status: ${
|
|
72
|
+
if (info.status !== 'ready' || !info.workspace_directory) {
|
|
73
|
+
await command.editReply(`Worktree is not ready (status: ${info.status})${info.error_message ? `: ${info.error_message}` : ''}`);
|
|
73
74
|
return;
|
|
74
75
|
}
|
|
75
76
|
const rawTargetBranch = command.options.getString('target-branch') || undefined;
|
|
76
77
|
let targetBranch = rawTargetBranch;
|
|
77
78
|
if (targetBranch) {
|
|
78
79
|
const validated = await validateBranchRef({
|
|
79
|
-
directory:
|
|
80
|
+
directory: info.project_directory,
|
|
80
81
|
ref: targetBranch,
|
|
81
82
|
});
|
|
82
83
|
if (validated instanceof Error) {
|
|
@@ -86,9 +87,9 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
|
|
|
86
87
|
targetBranch = validated;
|
|
87
88
|
}
|
|
88
89
|
const result = await mergeWorktree({
|
|
89
|
-
worktreeDir:
|
|
90
|
-
mainRepoDir:
|
|
91
|
-
worktreeName:
|
|
90
|
+
worktreeDir: info.workspace_directory,
|
|
91
|
+
mainRepoDir: info.project_directory,
|
|
92
|
+
worktreeName: info.workspace_name,
|
|
92
93
|
targetBranch,
|
|
93
94
|
onProgress: (msg) => {
|
|
94
95
|
logger.log(`[merge] ${msg}`);
|
|
@@ -126,7 +127,7 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
|
|
|
126
127
|
'9. Once the rebase is fully complete, tell me so I can run `/merge-worktree` again',
|
|
127
128
|
].join('\n'),
|
|
128
129
|
thread,
|
|
129
|
-
projectDirectory:
|
|
130
|
+
projectDirectory: info.project_directory,
|
|
130
131
|
command,
|
|
131
132
|
appId,
|
|
132
133
|
});
|
|
@@ -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
|
-
import { splitTablesFromMarkdown } from '../format-tables.js';
|
|
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.
|
|
@@ -132,7 +133,9 @@ function buildWorktreeTable({ rows, gitStatuses, guildId, }) {
|
|
|
132
133
|
return parts.join(', ');
|
|
133
134
|
})();
|
|
134
135
|
const created = row.createdAt ? formatTimeAgo(row.createdAt) : '-';
|
|
135
|
-
|
|
136
|
+
// Show only the last 2 path segments to keep text size under Discord's
|
|
137
|
+
// 4000-char displayable text limit. Full paths are too long.
|
|
138
|
+
const folder = `…/${path.basename(path.dirname(row.directory))}/${path.basename(row.directory)}`;
|
|
136
139
|
const action = buildActionCell({ row, gitStatus: gs });
|
|
137
140
|
return `| ${sourceCell} | ${name} | ${status} | ${created} | ${folder} | ${action} |`;
|
|
138
141
|
});
|
|
@@ -142,11 +145,7 @@ function buildActionCell({ row, gitStatus, }) {
|
|
|
142
145
|
if (!canDeleteWorktree({ row, gitStatus })) {
|
|
143
146
|
return '-';
|
|
144
147
|
}
|
|
145
|
-
|
|
146
|
-
buttonId: `del-wt-${worktreeButtonKey(row.directory)}`,
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
function buildDeleteButtonHtml({ buttonId, }) {
|
|
148
|
+
const buttonId = `del-wt-${worktreeButtonKey(row.directory)}`;
|
|
150
149
|
return `<button id="${buttonId}" variant="secondary">Delete</button>`;
|
|
151
150
|
}
|
|
152
151
|
function canDeleteWorktree({ row, gitStatus, }) {
|
|
@@ -196,21 +195,24 @@ async function resolveGitStatuses({ rows, projectDirectory, timeout, }) {
|
|
|
196
195
|
// in the git list (pending/error) are appended at the end.
|
|
197
196
|
async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
|
|
198
197
|
const db = await getDb();
|
|
199
|
-
const
|
|
198
|
+
const dbWorkspaces = await db.query.thread_workspaces.findMany({
|
|
200
199
|
where: { project_directory: projectDirectory },
|
|
201
200
|
});
|
|
202
|
-
|
|
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
|
|
203
207
|
const dbByDirectory = new Map();
|
|
204
|
-
for (const
|
|
205
|
-
if (
|
|
206
|
-
dbByDirectory.set(
|
|
208
|
+
for (const ws of dbWorkspaces) {
|
|
209
|
+
if (ws.workspace_directory) {
|
|
210
|
+
dbByDirectory.set(ws.workspace_directory, ws);
|
|
207
211
|
}
|
|
208
212
|
}
|
|
209
213
|
// Track which DB rows got matched so we can append unmatched ones
|
|
210
214
|
const matchedDbThreadIds = new Set();
|
|
211
215
|
// Build rows from git worktrees (the source of truth for on-disk state).
|
|
212
|
-
// Use real DB status when available — a git-visible worktree whose DB row
|
|
213
|
-
// is still 'pending' means setup hasn't finished (race window).
|
|
214
216
|
const gitRows = gitWorktrees.map((gw) => {
|
|
215
217
|
const dbMatch = dbByDirectory.get(gw.directory);
|
|
216
218
|
if (dbMatch) {
|
|
@@ -222,15 +224,12 @@ async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
|
|
|
222
224
|
});
|
|
223
225
|
const name = gw.branch ?? path.basename(gw.directory);
|
|
224
226
|
const dbStatus = (() => {
|
|
225
|
-
if (!dbMatch)
|
|
227
|
+
if (!dbMatch)
|
|
226
228
|
return 'ready';
|
|
227
|
-
|
|
228
|
-
if (dbMatch.status === 'error') {
|
|
229
|
+
if (dbMatch.status === 'error')
|
|
229
230
|
return 'error';
|
|
230
|
-
|
|
231
|
-
if (dbMatch.status === 'pending') {
|
|
231
|
+
if (dbMatch.status === 'pending')
|
|
232
232
|
return 'pending';
|
|
233
|
-
}
|
|
234
233
|
return 'ready';
|
|
235
234
|
})();
|
|
236
235
|
return {
|
|
@@ -238,48 +237,33 @@ async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
|
|
|
238
237
|
branch: gw.branch,
|
|
239
238
|
name,
|
|
240
239
|
threadId: dbMatch?.thread_id ?? null,
|
|
241
|
-
guildId: null,
|
|
242
|
-
createdAt: dbMatch?.created_at
|
|
240
|
+
guildId: null,
|
|
241
|
+
createdAt: toDate(dbMatch?.created_at),
|
|
243
242
|
source,
|
|
243
|
+
workspaceId: dbMatch?.workspace_id ?? null,
|
|
244
244
|
dbStatus,
|
|
245
245
|
locked: gw.locked,
|
|
246
246
|
prunable: gw.prunable,
|
|
247
247
|
};
|
|
248
248
|
});
|
|
249
|
-
// Append DB-only
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return {
|
|
266
|
-
directory: dbWt.worktree_directory ?? dbWt.project_directory,
|
|
267
|
-
branch: null,
|
|
268
|
-
name: dbWt.worktree_name,
|
|
269
|
-
threadId: dbWt.thread_id,
|
|
270
|
-
guildId: null,
|
|
271
|
-
createdAt: dbWt.created_at,
|
|
272
|
-
source: 'kimaki',
|
|
273
|
-
dbStatus,
|
|
274
|
-
locked: false,
|
|
275
|
-
prunable: false,
|
|
276
|
-
};
|
|
277
|
-
});
|
|
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
|
+
}));
|
|
278
265
|
return [...gitRows, ...dbOnlyRows];
|
|
279
266
|
}
|
|
280
|
-
function getWorktreesActionOwnerKey({ userId, channelId, }) {
|
|
281
|
-
return `worktrees:${userId}:${channelId}`;
|
|
282
|
-
}
|
|
283
267
|
function isProjectChannel(channel) {
|
|
284
268
|
if (!channel) {
|
|
285
269
|
return false;
|
|
@@ -291,8 +275,19 @@ function isProjectChannel(channel) {
|
|
|
291
275
|
ChannelType.AnnouncementThread,
|
|
292
276
|
].includes(channel.type);
|
|
293
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
|
+
}
|
|
294
289
|
async function renderWorktreesReply({ guildId, userId, channelId, projectDirectory, notice, editReply, }) {
|
|
295
|
-
const ownerKey =
|
|
290
|
+
const ownerKey = `worktrees:${userId}:${channelId}`;
|
|
296
291
|
cancelHtmlActionsForOwner(ownerKey);
|
|
297
292
|
const gitWorktrees = await listGitWorktrees({
|
|
298
293
|
projectDirectory,
|
|
@@ -360,7 +355,7 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
|
|
|
360
355
|
return buildHtmlActionCustomId(actionId);
|
|
361
356
|
},
|
|
362
357
|
});
|
|
363
|
-
const
|
|
358
|
+
const allComponents = segments.flatMap((segment) => {
|
|
364
359
|
if (segment.type === 'components') {
|
|
365
360
|
return segment.components;
|
|
366
361
|
}
|
|
@@ -370,6 +365,20 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
|
|
|
370
365
|
};
|
|
371
366
|
return [textDisplay];
|
|
372
367
|
});
|
|
368
|
+
// Reserve budget for a truncation notice (1 component + its text length)
|
|
369
|
+
// so appending the notice doesn't push us over either Discord limit.
|
|
370
|
+
const truncatedNoticeContent = `*Some worktrees were not shown due to Discord's component limit. Use \`git worktree list\` for the full list.*`;
|
|
371
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
372
|
+
reserveCost: 1,
|
|
373
|
+
reserveTextSize: truncatedNoticeContent.length,
|
|
374
|
+
});
|
|
375
|
+
if (truncated) {
|
|
376
|
+
const truncatedNotice = {
|
|
377
|
+
type: ComponentType.TextDisplay,
|
|
378
|
+
content: truncatedNoticeContent,
|
|
379
|
+
};
|
|
380
|
+
components.push(truncatedNotice);
|
|
381
|
+
}
|
|
373
382
|
await editReply({
|
|
374
383
|
components,
|
|
375
384
|
flags: MessageFlags.IsComponentsV2,
|
|
@@ -389,14 +398,17 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
|
|
|
389
398
|
});
|
|
390
399
|
return;
|
|
391
400
|
}
|
|
392
|
-
//
|
|
393
|
-
//
|
|
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.
|
|
394
404
|
const displayName = row.branch ?? row.name;
|
|
395
|
-
const deleteResult =
|
|
396
|
-
projectDirectory,
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
});
|
|
400
412
|
if (deleteResult instanceof Error) {
|
|
401
413
|
const gitStderr = extractGitStderr(deleteResult);
|
|
402
414
|
const detail = gitStderr
|
|
@@ -412,9 +424,8 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
|
|
|
412
424
|
});
|
|
413
425
|
return;
|
|
414
426
|
}
|
|
415
|
-
// Clean up DB row if this was a kimaki-tracked worktree
|
|
416
427
|
if (row.threadId) {
|
|
417
|
-
await
|
|
428
|
+
await deleteThreadWorkspace(row.threadId);
|
|
418
429
|
}
|
|
419
430
|
await renderWorktreesReply({
|
|
420
431
|
guildId,
|
|
@@ -427,6 +438,19 @@ async function handleDeleteWorktreeAction({ interaction, row, projectDirectory,
|
|
|
427
438
|
},
|
|
428
439
|
});
|
|
429
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
|
+
}
|
|
430
454
|
export async function handleWorktreesCommand({ command, }) {
|
|
431
455
|
const channel = command.channel;
|
|
432
456
|
const guildId = command.guildId;
|
|
@@ -444,9 +468,7 @@ export async function handleWorktreesCommand({ command, }) {
|
|
|
444
468
|
});
|
|
445
469
|
return;
|
|
446
470
|
}
|
|
447
|
-
const resolved = await
|
|
448
|
-
channel: channel,
|
|
449
|
-
});
|
|
471
|
+
const resolved = await resolveWorktreesWorkingDirectory(channel);
|
|
450
472
|
if (!resolved) {
|
|
451
473
|
await command.reply({
|
|
452
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 () => {
|