@loicngr/kobo 1.11.9 → 1.11.10
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/AGENTS.md +2 -1
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/dist/server/db/migrations.js +155 -19
- package/dist/server/db/schema.js +93 -0
- package/dist/server/index.js +35 -26
- package/dist/server/routes/dev-server.js +8 -7
- package/dist/server/routes/documents.js +33 -3
- package/dist/server/routes/health.js +16 -15
- package/dist/server/routes/images.js +17 -2
- package/dist/server/routes/workspaces.js +63 -48
- package/dist/server/services/agent/orchestrator.js +8 -1
- package/dist/server/services/auto-loop-service.js +1 -1
- package/dist/server/services/dev-server-service.js +48 -41
- package/dist/server/services/forge/bitbucket/provider.js +6 -11
- package/dist/server/services/forge/github/provider.js +10 -9
- package/dist/server/services/forge/gitlab/provider.js +11 -13
- package/dist/server/services/git-stats-service.js +8 -6
- package/dist/server/services/image-service.js +34 -5
- package/dist/server/services/pr-watcher-service.js +20 -9
- package/dist/server/services/search-service.js +53 -40
- package/dist/server/services/websocket-service.js +8 -3
- package/dist/server/services/workspace-service.js +81 -10
- package/dist/server/services/worktree-purge-service.js +1 -1
- package/dist/server/utils/git-ops.js +89 -22
- package/dist/server/utils/safe-path.js +14 -0
- package/package.json +22 -22
- package/src/client/dist/pwa/assets/{ActivityFeed-Dv06XbgS.css → ActivityFeed-BrjeLlhi.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-UEAexGjc.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-XSYwu9XA.js +1 -0
- package/src/client/dist/pwa/assets/{ClosePopup-BZ1O0Jxe.js → ClosePopup-dpcrW9DC.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-BK__rjvR.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-QwtfYunr.css +1 -0
- package/src/client/dist/pwa/assets/{DiffViewer-D9FN4RS8.css → DiffViewer-CS3P9Ngi.css} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-DnFN6Sc_.js +8 -0
- package/src/client/dist/pwa/assets/HealthPage-DVrZfMI6.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-D_3nFaCG.js +19 -0
- package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +1 -0
- package/src/client/dist/pwa/assets/QBadge-sMJZ_eZm.js +1 -0
- package/src/client/dist/pwa/assets/QBanner-DedhGMcj.js +1 -0
- package/src/client/dist/pwa/assets/QBtnToggle-BgfN19l8.js +1 -0
- package/src/client/dist/pwa/assets/QChip-EQGYShN0.js +1 -0
- package/src/client/dist/pwa/assets/QIcon--SvgE-nS.js +1 -0
- package/src/client/dist/pwa/assets/QList-Be_FbTA3.js +1 -0
- package/src/client/dist/pwa/assets/QPage-DuIsRmYu.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-CtxxliRU.js +36 -0
- package/src/client/dist/pwa/assets/QSeparator-BdyRE-AM.js +1 -0
- package/src/client/dist/pwa/assets/QSpace-D9UEvM99.js +1 -0
- package/src/client/dist/pwa/assets/QSpinnerDots-B4RmFJUj.js +1 -0
- package/src/client/dist/pwa/assets/QTooltip-HEIFh5w9.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-1B5HOf90.js +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-v8f4rrCt.js +16 -0
- package/src/client/dist/pwa/assets/TouchPan-lynWXY69.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-ChnyJ3xI.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-Vjzi8YnW.js +4 -0
- package/src/client/dist/pwa/assets/codicon-Brq4_Ui5.ttf +0 -0
- package/src/client/dist/pwa/assets/{css.worker-CBlhdqTa.js → css.worker-JKZQyld4.js} +38 -34
- package/src/client/dist/pwa/assets/cssMode-cg8iVI-i.js +1 -0
- package/src/client/dist/pwa/assets/documents-Dlx8yiSO.js +1 -0
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-CfaJRkrX.js +847 -0
- package/src/client/dist/pwa/assets/editor.worker-D-m8IBMa.js +30 -0
- package/src/client/dist/pwa/assets/expand-template-D3LZldj1.js +1 -0
- package/src/client/dist/pwa/assets/formatters-D0dU6VcA.js +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-Dgrv0PyH.js → freemarker2-B_8OIClK.js} +1 -1
- package/src/client/dist/pwa/assets/handlebars-CaYi_uxU.js +1 -0
- package/src/client/dist/pwa/assets/html-BnCUDUl1.js +1 -0
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +506 -0
- package/src/client/dist/pwa/assets/htmlMode-BZjKGnQF.js +1 -0
- package/src/client/dist/pwa/assets/i18n-B6SDs7Lm.js +1 -0
- package/src/client/dist/pwa/assets/index-CK1nk7qr.css +1 -0
- package/src/client/dist/pwa/assets/index-CoOsPyyG.js +82 -0
- package/src/client/dist/pwa/assets/{javascript-IK9qSsfx.js → javascript-DkSfoeHU.js} +1 -1
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +62 -0
- package/src/client/dist/pwa/assets/{jsonMode-D_Vdxb-B.js → jsonMode-yKXxfx1V.js} +3 -3
- package/src/client/dist/pwa/assets/{kobo-commands-zljfAram.js → kobo-commands-vn1IQxLn.js} +1 -1
- package/src/client/dist/pwa/assets/layout-CiQpugQB.js +1 -0
- package/src/client/dist/pwa/assets/liquid-Dlhysv3U.js +1 -0
- package/src/client/dist/pwa/assets/lspLanguageFeatures-CeYSQFUz.js +4 -0
- package/src/client/dist/pwa/assets/mdx-B8iME5NA.js +1 -0
- package/src/client/dist/pwa/assets/network-auth-j2Drut6q.js +1 -0
- package/src/client/dist/pwa/assets/pending-diff-open-B4RYz7G_.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-6jhegAm-.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-6xNNY9u6.js → project-color-e3OdFi9X.js} +1 -1
- package/src/client/dist/pwa/assets/python-CbFbC0ZR.js +1 -0
- package/src/client/dist/pwa/assets/razor-7c2NCdFI.js +1 -0
- package/src/client/dist/pwa/assets/reactivity.esm-bundler-DtSZKRQB.js +1 -0
- package/src/client/dist/pwa/assets/render-chat-markdown-Fer8kY38.js +67 -0
- package/src/client/dist/pwa/assets/runtime-dom.esm-bundler-DgevF6KV.js +4 -0
- package/src/client/dist/pwa/assets/{ts.worker-PmaSgaZk.js → ts.worker-C-oGa8JT.js} +199 -195
- package/src/client/dist/pwa/assets/{tsMode-DiiMWOVl.js → tsMode-BmShKjbx.js} +1 -1
- package/src/client/dist/pwa/assets/typescript-Dhu7Upbs.js +1 -0
- package/src/client/dist/pwa/assets/use-checkbox-SrkgiJho.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-CTZuBZOi.css +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-fe4sPCNf.js +2 -0
- package/src/client/dist/pwa/assets/use-quasar-CeHlTrGg.js +1 -0
- package/src/client/dist/pwa/assets/vs-B-xsceNQ.js +56 -0
- package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +1 -0
- package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +3 -0
- package/src/client/dist/pwa/assets/workers-zVMTXVoC.js +1 -0
- package/src/client/dist/pwa/assets/xml-D64wpCtR.js +1 -0
- package/src/client/dist/pwa/assets/yaml-CLSRKwfx.js +1 -0
- package/src/client/dist/pwa/index.html +8 -8
- package/src/client/dist/pwa/manifest.json +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-Bs1uccfa.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-nW4vA3qx.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BrdHh0Th.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-CRIBi2Vx.css +0 -1
- package/src/client/dist/pwa/assets/DiffViewer-DhtJQzG2.js +0 -8
- package/src/client/dist/pwa/assets/HealthPage-CExFCl_7.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-Coqyj1FP.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-WYZAjpD2.js +0 -37
- package/src/client/dist/pwa/assets/QBadge-DV5kn4mL.js +0 -1
- package/src/client/dist/pwa/assets/QBanner-CmfaeizG.js +0 -1
- package/src/client/dist/pwa/assets/QBtnGroup-C442-yBb.js +0 -1
- package/src/client/dist/pwa/assets/QBtnToggle-CqyoLg5U.js +0 -1
- package/src/client/dist/pwa/assets/QChip-DMYWc1jc.js +0 -1
- package/src/client/dist/pwa/assets/QIcon-C6E-oEjI.js +0 -1
- package/src/client/dist/pwa/assets/QInput-BF8x4RTv.js +0 -1
- package/src/client/dist/pwa/assets/QList-D5EKdFPn.js +0 -1
- package/src/client/dist/pwa/assets/QPage-D3rjwA1l.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-CAa3syxF.js +0 -36
- package/src/client/dist/pwa/assets/QSeparator-DWTCQxsa.js +0 -1
- package/src/client/dist/pwa/assets/QSpace-ezNrZkuj.js +0 -1
- package/src/client/dist/pwa/assets/QSpinnerDots-DIV68-cV.js +0 -1
- package/src/client/dist/pwa/assets/QTooltip-6IQJP1_Y.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CQtm05UQ.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-B38IzmSs.js +0 -16
- package/src/client/dist/pwa/assets/ThinkingItem-BqWpfNZP.js +0 -1
- package/src/client/dist/pwa/assets/ThinkingItem-Ckk4K1O-.css +0 -1
- package/src/client/dist/pwa/assets/TouchPan-C939psBh.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-CFF5Ghug.css +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-dS-Nc6QU.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-C5rzY2hF.js +0 -1
- package/src/client/dist/pwa/assets/chunk-QTnfLwEv.js +0 -1
- package/src/client/dist/pwa/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/src/client/dist/pwa/assets/cssMode-p60S_iK6.js +0 -1
- package/src/client/dist/pwa/assets/documents-CzlF-PFm.js +0 -1
- package/src/client/dist/pwa/assets/editor-BFebQ78e.css +0 -1
- package/src/client/dist/pwa/assets/editor.api2-DfOm2dM5.js +0 -872
- package/src/client/dist/pwa/assets/editor.main-6eriWmTe.js +0 -6
- package/src/client/dist/pwa/assets/editor.worker-Cu3tR8iJ.js +0 -26
- package/src/client/dist/pwa/assets/expand-template-B_RAt6BV.js +0 -1
- package/src/client/dist/pwa/assets/formatters-BoLOujHM.js +0 -1
- package/src/client/dist/pwa/assets/handlebars-D0XTaj97.js +0 -1
- package/src/client/dist/pwa/assets/html-DkYxFVm6.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BKBrNna1.js +0 -502
- package/src/client/dist/pwa/assets/htmlMode-Dj3mtvXI.js +0 -1
- package/src/client/dist/pwa/assets/i18n-Dzzn4YfF.js +0 -1
- package/src/client/dist/pwa/assets/index-C06a940j.js +0 -84
- package/src/client/dist/pwa/assets/index-CwYR3iHt.css +0 -1
- package/src/client/dist/pwa/assets/json.worker-BaLYt9Ss.js +0 -58
- package/src/client/dist/pwa/assets/layout-BAnzeEPY.js +0 -1
- package/src/client/dist/pwa/assets/liquid-DURTrym0.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-Bki_90YN.js +0 -4
- package/src/client/dist/pwa/assets/mdx-z7FKEEaF.js +0 -1
- package/src/client/dist/pwa/assets/monaco.contribution-D52lvpFV.js +0 -2
- package/src/client/dist/pwa/assets/network-auth-DrGWXKl2.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-DX42h-5U.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-DMHgg-69.js +0 -1
- package/src/client/dist/pwa/assets/python-DHQ3U-kG.js +0 -1
- package/src/client/dist/pwa/assets/razor-DMlreu1b.js +0 -1
- package/src/client/dist/pwa/assets/render-chat-markdown-0Vj9B94m.js +0 -66
- package/src/client/dist/pwa/assets/typescript-0wxHr6dC.js +0 -1
- package/src/client/dist/pwa/assets/use-checkbox-E9SV7iH5.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding--YqDGUQR.js +0 -2
- package/src/client/dist/pwa/assets/use-onboarding-B3l7mx48.css +0 -1
- package/src/client/dist/pwa/assets/use-quasar-oX1TyrPM.js +0 -1
- package/src/client/dist/pwa/assets/vue.runtime.esm-bundler-JZnIeD9D.js +0 -3
- package/src/client/dist/pwa/assets/workers-D0OEUSgt.js +0 -1
- package/src/client/dist/pwa/assets/xml-C7PczWV5.js +0 -1
- package/src/client/dist/pwa/assets/yaml-Dj6QPnyC.js +0 -1
|
@@ -3,6 +3,10 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
const CLI_TIMEOUT_MS = 30_000;
|
|
7
|
+
function cliOptions(repoPath) {
|
|
8
|
+
return { cwd: repoPath, encoding: 'utf-8', timeout: CLI_TIMEOUT_MS };
|
|
9
|
+
}
|
|
6
10
|
/** Map GitLab MR state to the normalised PrSnapshot state. */
|
|
7
11
|
function mapState(state) {
|
|
8
12
|
if (state === 'merged')
|
|
@@ -100,10 +104,7 @@ function mapGlabJobToCheck(job) {
|
|
|
100
104
|
*/
|
|
101
105
|
async function fetchGlabCi(repoPath, branch) {
|
|
102
106
|
try {
|
|
103
|
-
const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'],
|
|
104
|
-
cwd: repoPath,
|
|
105
|
-
encoding: 'utf-8',
|
|
106
|
-
});
|
|
107
|
+
const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'], cliOptions(repoPath));
|
|
107
108
|
const raw = stdout.trim();
|
|
108
109
|
if (!raw)
|
|
109
110
|
return { rollup: null, checks: [] };
|
|
@@ -138,7 +139,7 @@ export const gitlabProvider = {
|
|
|
138
139
|
},
|
|
139
140
|
async isAvailable(repoPath) {
|
|
140
141
|
try {
|
|
141
|
-
await execFileAsync('glab', ['auth', 'status'],
|
|
142
|
+
await execFileAsync('glab', ['auth', 'status'], cliOptions(repoPath));
|
|
142
143
|
return { available: true };
|
|
143
144
|
}
|
|
144
145
|
catch (err) {
|
|
@@ -147,10 +148,7 @@ export const gitlabProvider = {
|
|
|
147
148
|
},
|
|
148
149
|
async getPrStatus(repoPath, branch) {
|
|
149
150
|
try {
|
|
150
|
-
const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'],
|
|
151
|
-
cwd: repoPath,
|
|
152
|
-
encoding: 'utf-8',
|
|
153
|
-
});
|
|
151
|
+
const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'], cliOptions(repoPath));
|
|
154
152
|
const raw = stdout.trim();
|
|
155
153
|
if (!raw)
|
|
156
154
|
return null;
|
|
@@ -176,19 +174,19 @@ export const gitlabProvider = {
|
|
|
176
174
|
'--description',
|
|
177
175
|
opts.body,
|
|
178
176
|
'--yes',
|
|
179
|
-
],
|
|
177
|
+
], cliOptions(repoPath));
|
|
180
178
|
const match = stdout.match(/https?:\/\/[^\s]+\/-\/merge_requests\/(\d+)/);
|
|
181
179
|
if (!match)
|
|
182
180
|
throw new Error('Could not parse MR URL from glab output');
|
|
183
181
|
return { url: match[0], number: Number.parseInt(match[1], 10) };
|
|
184
182
|
},
|
|
185
183
|
async changePrBase(repoPath, base) {
|
|
186
|
-
await execFileAsync('glab', ['mr', 'update', '--target-branch', base],
|
|
184
|
+
await execFileAsync('glab', ['mr', 'update', '--target-branch', base], cliOptions(repoPath));
|
|
187
185
|
},
|
|
188
186
|
async mergeRequest(repoPath, number) {
|
|
189
|
-
await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'],
|
|
187
|
+
await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'], cliOptions(repoPath));
|
|
190
188
|
},
|
|
191
189
|
async deleteRemoteBranch(repoPath, branch) {
|
|
192
|
-
await execFileAsync('git', ['push', 'origin', '--delete', branch],
|
|
190
|
+
await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
|
|
193
191
|
},
|
|
194
192
|
};
|
|
@@ -10,13 +10,15 @@ import { resolveForge } from './forge/resolve.js';
|
|
|
10
10
|
*/
|
|
11
11
|
export async function computeGitStats(workspace, prSnapshot) {
|
|
12
12
|
const { worktreePath, sourceBranch, workingBranch, projectPath } = workspace;
|
|
13
|
-
const commitCount = gitOps.getCommitCount(worktreePath, sourceBranch, workingBranch);
|
|
14
|
-
const behindCount = gitOps.getCommitsBehind(worktreePath, sourceBranch, workingBranch);
|
|
15
|
-
const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, sourceBranch, workingBranch);
|
|
16
|
-
const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath, workingBranch);
|
|
17
|
-
const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
|
|
18
13
|
const forgeProvider = getForgeProvider(resolveForge(projectPath));
|
|
19
|
-
const availability = await
|
|
14
|
+
const [commitCount, behindCount, diffStats, unpushedCount, workingTree, availability] = await Promise.all([
|
|
15
|
+
gitOps.getCommitCountAsync(worktreePath, sourceBranch, workingBranch),
|
|
16
|
+
gitOps.getCommitsBehindAsync(worktreePath, sourceBranch, workingBranch),
|
|
17
|
+
gitOps.getStructuredDiffStatsBetweenAsync(worktreePath, sourceBranch, workingBranch),
|
|
18
|
+
gitOps.getUnpushedCountAsync(worktreePath, workingBranch),
|
|
19
|
+
gitOps.getWorkingTreeStatusAsync(worktreePath),
|
|
20
|
+
forgeProvider.isAvailable(worktreePath),
|
|
21
|
+
]);
|
|
20
22
|
return {
|
|
21
23
|
commitCount,
|
|
22
24
|
behindCount,
|
|
@@ -6,6 +6,29 @@ const IMAGES_DIR = '.ai/images';
|
|
|
6
6
|
const INDEX_FILE = 'index.json';
|
|
7
7
|
// Per-worktree lock to serialize index.json writes
|
|
8
8
|
const locks = new Map();
|
|
9
|
+
function ensureRealImagesDirectory(worktreePath) {
|
|
10
|
+
const realWorktree = fs.realpathSync(worktreePath);
|
|
11
|
+
let current = worktreePath;
|
|
12
|
+
for (const segment of ['.ai', 'images']) {
|
|
13
|
+
current = path.join(current, segment);
|
|
14
|
+
if (fs.existsSync(current)) {
|
|
15
|
+
const stat = fs.lstatSync(current);
|
|
16
|
+
if (stat.isSymbolicLink())
|
|
17
|
+
throw new Error(`Symbolic links are not allowed in image storage: ${current}`);
|
|
18
|
+
if (!stat.isDirectory())
|
|
19
|
+
throw new Error(`Image storage path is not a directory: ${current}`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
fs.mkdirSync(current);
|
|
23
|
+
}
|
|
24
|
+
const realCurrent = fs.realpathSync(current);
|
|
25
|
+
const relative = path.relative(realWorktree, realCurrent);
|
|
26
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
27
|
+
throw new Error('Image storage path escapes the worktree');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return fs.realpathSync(current);
|
|
31
|
+
}
|
|
9
32
|
function withLock(worktreePath, fn) {
|
|
10
33
|
const prev = locks.get(worktreePath) ?? Promise.resolve();
|
|
11
34
|
// The second argument to .then() means: even if the previous operation in the
|
|
@@ -18,6 +41,8 @@ function readIndex(imagesDir) {
|
|
|
18
41
|
const indexPath = path.join(imagesDir, INDEX_FILE);
|
|
19
42
|
if (!fs.existsSync(indexPath))
|
|
20
43
|
return [];
|
|
44
|
+
if (fs.lstatSync(indexPath).isSymbolicLink())
|
|
45
|
+
throw new Error('Symbolic links are not allowed for image index');
|
|
21
46
|
try {
|
|
22
47
|
return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
23
48
|
}
|
|
@@ -27,7 +52,13 @@ function readIndex(imagesDir) {
|
|
|
27
52
|
}
|
|
28
53
|
}
|
|
29
54
|
function writeIndex(imagesDir, entries) {
|
|
30
|
-
|
|
55
|
+
const indexPath = path.join(imagesDir, INDEX_FILE);
|
|
56
|
+
if (fs.existsSync(indexPath) && fs.lstatSync(indexPath).isSymbolicLink()) {
|
|
57
|
+
throw new Error('Symbolic links are not allowed for image index');
|
|
58
|
+
}
|
|
59
|
+
const tempPath = path.join(imagesDir, `.index-${process.pid}.tmp`);
|
|
60
|
+
fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2));
|
|
61
|
+
fs.renameSync(tempPath, indexPath);
|
|
31
62
|
}
|
|
32
63
|
/** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
|
|
33
64
|
export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
@@ -39,9 +70,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
|
39
70
|
throw new Error(`Unsupported image extension: '${ext}'. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
|
|
40
71
|
}
|
|
41
72
|
const uid = nanoid(10);
|
|
42
|
-
const imagesDir =
|
|
43
|
-
// mkdirSync is idempotent — safe to call outside the lock
|
|
44
|
-
fs.mkdirSync(imagesDir, { recursive: true });
|
|
73
|
+
const imagesDir = ensureRealImagesDirectory(worktreePath);
|
|
45
74
|
const filename = `${uid}.${ext}`;
|
|
46
75
|
await withLock(worktreePath, () => {
|
|
47
76
|
// Write the image file inside the lock so both the file write and index
|
|
@@ -55,7 +84,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
|
55
84
|
}
|
|
56
85
|
/** Delete an image by UID from disk and the index. */
|
|
57
86
|
export async function deleteImage(worktreePath, uid) {
|
|
58
|
-
const imagesDir =
|
|
87
|
+
const imagesDir = ensureRealImagesDirectory(worktreePath);
|
|
59
88
|
await withLock(worktreePath, () => {
|
|
60
89
|
const entries = readIndex(imagesDir);
|
|
61
90
|
const idx = entries.findIndex((e) => e.uid === uid);
|
|
@@ -18,8 +18,19 @@ import { purgeWorktree } from './worktree-purge-service.js';
|
|
|
18
18
|
// it is recorded but NOT acted upon — prevents re-archiving manually
|
|
19
19
|
// unarchived workspaces.
|
|
20
20
|
const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
|
|
21
|
+
const WORKSPACE_CHECK_CONCURRENCY = 4;
|
|
21
22
|
let timer = null;
|
|
22
23
|
let checking = false;
|
|
24
|
+
async function runBounded(items, concurrency, worker) {
|
|
25
|
+
let nextIndex = 0;
|
|
26
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
27
|
+
while (nextIndex < items.length) {
|
|
28
|
+
const item = items[nextIndex++];
|
|
29
|
+
await worker(item);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
await Promise.all(workers);
|
|
33
|
+
}
|
|
23
34
|
/** Tracks the last known PR snapshot per workspace, used to detect transitions
|
|
24
35
|
* (state, base, reviewDecision). */
|
|
25
36
|
const lastKnownPr = new Map();
|
|
@@ -110,11 +121,11 @@ export async function checkPrStatuses() {
|
|
|
110
121
|
lastKnownGitStats.delete(id);
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
|
-
|
|
124
|
+
await runBounded(workspaces, WORKSPACE_CHECK_CONCURRENCY, async (ws) => {
|
|
114
125
|
// Without this guard, every git/forge spawn below fails with ENOENT and
|
|
115
126
|
// floods the logs when a worktree was deleted externally.
|
|
116
127
|
if (!fs.existsSync(ws.worktreePath))
|
|
117
|
-
|
|
128
|
+
return;
|
|
118
129
|
try {
|
|
119
130
|
// Opt-out: skip the forge call entirely for a workspace with PR-watch
|
|
120
131
|
// disabled — no PR-status fetch, no auto-archive/purge, no PR-derived
|
|
@@ -143,7 +154,7 @@ export async function checkPrStatuses() {
|
|
|
143
154
|
console.error(`[pr-watcher] updateWorkspaceSourceBranch failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
144
155
|
// Leave the cache untouched so the next tick retries — and skip
|
|
145
156
|
// stats too, since they'd be computed against the stale base.
|
|
146
|
-
|
|
157
|
+
return;
|
|
147
158
|
}
|
|
148
159
|
}
|
|
149
160
|
}
|
|
@@ -158,7 +169,7 @@ export async function checkPrStatuses() {
|
|
|
158
169
|
console.error(`[pr-watcher] computeGitStats failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
159
170
|
}
|
|
160
171
|
if (!pr)
|
|
161
|
-
|
|
172
|
+
return;
|
|
162
173
|
const prev = lastKnownPr.get(ws.id);
|
|
163
174
|
// We delay updating `lastKnownPr` until after the actions succeed.
|
|
164
175
|
// Setting it eagerly would poison the cache: if updateWorkspaceSourceBranch
|
|
@@ -179,13 +190,13 @@ export async function checkPrStatuses() {
|
|
|
179
190
|
// (The defensive base preservation from the no-base branch doesn't apply here
|
|
180
191
|
// because we ARE in the OPEN→MERGED/CLOSED branch which always has a base.)
|
|
181
192
|
lastKnownPr.set(ws.id, pr);
|
|
182
|
-
|
|
193
|
+
return;
|
|
183
194
|
}
|
|
184
195
|
console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
|
|
185
196
|
// Best-effort cleanup (same as manual archive): stop dev server + terminal.
|
|
186
197
|
// Agent is already not running here (guarded above).
|
|
187
198
|
try {
|
|
188
|
-
stopDevServer(ws.id);
|
|
199
|
+
await stopDevServer(ws.id);
|
|
189
200
|
}
|
|
190
201
|
catch (err) {
|
|
191
202
|
console.error(`[pr-watcher] stopDevServer failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
@@ -217,7 +228,7 @@ export async function checkPrStatuses() {
|
|
|
217
228
|
console.error(`[pr-watcher] auto-purge guard failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
218
229
|
}
|
|
219
230
|
}
|
|
220
|
-
|
|
231
|
+
return; // do not run base-change detection on a workspace we just archived
|
|
221
232
|
}
|
|
222
233
|
// Review, CI, mergeability, and readiness transitions (only on OPEN PRs;
|
|
223
234
|
// first-sight is silent). Each check is independent so simultaneous
|
|
@@ -254,7 +265,7 @@ export async function checkPrStatuses() {
|
|
|
254
265
|
if (pr.state !== 'OPEN' || !pr.base) {
|
|
255
266
|
const next = pr.base ? pr : { ...pr, base: prev?.base ?? pr.base };
|
|
256
267
|
lastKnownPr.set(ws.id, next);
|
|
257
|
-
|
|
268
|
+
return;
|
|
258
269
|
}
|
|
259
270
|
lastKnownPr.set(ws.id, pr);
|
|
260
271
|
// Emit the base-change event AFTER the snapshot is committed so a
|
|
@@ -272,7 +283,7 @@ export async function checkPrStatuses() {
|
|
|
272
283
|
catch (err) {
|
|
273
284
|
console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
274
285
|
}
|
|
275
|
-
}
|
|
286
|
+
});
|
|
276
287
|
}
|
|
277
288
|
/**
|
|
278
289
|
* On-demand refresh of a single workspace's PR snapshot. Bypasses the 30s tick.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getDb } from '../db/index.js';
|
|
2
2
|
/** Event types that carry user-authored or assistant-authored readable text. */
|
|
3
|
-
const SEARCHABLE_TYPES = ['user:message', 'agent:output'];
|
|
3
|
+
const SEARCHABLE_TYPES = ['user:message', 'agent:event', 'agent:output'];
|
|
4
4
|
const SNIPPET_CONTEXT = 100; // chars on each side of the match
|
|
5
5
|
/**
|
|
6
6
|
* Extract the readable text content of a ws_events payload, or `null` when
|
|
@@ -29,6 +29,9 @@ function extractReadableText(type, payload) {
|
|
|
29
29
|
return parts.length > 0 ? parts.join('\n') : null;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
if (type === 'agent:event' && p.kind === 'message:text') {
|
|
33
|
+
return typeof p.text === 'string' ? p.text : null;
|
|
34
|
+
}
|
|
32
35
|
return null;
|
|
33
36
|
}
|
|
34
37
|
function buildSnippet(text, matchIndex, query) {
|
|
@@ -56,49 +59,59 @@ export function searchEvents(query, options = {}) {
|
|
|
56
59
|
const db = getDb();
|
|
57
60
|
const typePlaceholders = SEARCHABLE_TYPES.map(() => '?').join(', ');
|
|
58
61
|
const archiveFilter = includeArchived ? '' : 'AND w.archived_at IS NULL';
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
// realistic datasets, without scanning the entire table.
|
|
62
|
-
const dbLimit = Math.max(limit * 3, 100);
|
|
63
|
-
const rows = db
|
|
64
|
-
.prepare(`SELECT e.id, e.workspace_id, e.session_id, e.type, e.created_at, e.payload,
|
|
65
|
-
w.name AS workspace_name, w.archived_at
|
|
66
|
-
FROM ws_events e
|
|
67
|
-
JOIN workspaces w ON e.workspace_id = w.id
|
|
68
|
-
WHERE e.type IN (${typePlaceholders})
|
|
69
|
-
AND e.payload LIKE ?
|
|
70
|
-
${archiveFilter}
|
|
71
|
-
ORDER BY e.created_at DESC
|
|
72
|
-
LIMIT ?`)
|
|
73
|
-
.all(...SEARCHABLE_TYPES, likePattern, dbLimit);
|
|
62
|
+
const escapedQuery = trimmed.replace(/[\\%_]/g, '\\$&');
|
|
63
|
+
const likePattern = `%${escapedQuery}%`;
|
|
74
64
|
const needle = trimmed.toLowerCase();
|
|
75
65
|
const results = [];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
66
|
+
const batchSize = Math.min(Math.max(limit * 3, 100), 500);
|
|
67
|
+
const statement = db.prepare(`SELECT e.id, e.workspace_id, e.session_id, e.type, e.created_at, e.payload,
|
|
68
|
+
w.name AS workspace_name, w.archived_at
|
|
69
|
+
FROM ws_events e
|
|
70
|
+
JOIN workspaces w ON e.workspace_id = w.id
|
|
71
|
+
WHERE e.type IN (${typePlaceholders})
|
|
72
|
+
AND e.payload LIKE ? ESCAPE '\\'
|
|
73
|
+
${archiveFilter}
|
|
74
|
+
AND (? IS NULL OR e.created_at < ? OR (e.created_at = ? AND e.id < ?))
|
|
75
|
+
ORDER BY e.created_at DESC, e.id DESC
|
|
76
|
+
LIMIT ?`);
|
|
77
|
+
let cursorCreatedAt = null;
|
|
78
|
+
let cursorId = null;
|
|
79
|
+
while (results.length < limit) {
|
|
80
|
+
const rows = statement.all(...SEARCHABLE_TYPES, likePattern, cursorCreatedAt, cursorCreatedAt, cursorCreatedAt, cursorId, batchSize);
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(row.payload);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const text = extractReadableText(row.type, parsed);
|
|
90
|
+
if (!text)
|
|
91
|
+
continue;
|
|
92
|
+
const idx = text.toLowerCase().indexOf(needle);
|
|
93
|
+
if (idx < 0)
|
|
94
|
+
continue;
|
|
95
|
+
results.push({
|
|
96
|
+
eventId: row.id,
|
|
97
|
+
sessionId: row.session_id,
|
|
98
|
+
workspaceId: row.workspace_id,
|
|
99
|
+
workspaceName: row.workspace_name,
|
|
100
|
+
archived: row.archived_at !== null,
|
|
101
|
+
type: row.type,
|
|
102
|
+
timestamp: row.created_at,
|
|
103
|
+
snippet: buildSnippet(text, idx, trimmed),
|
|
104
|
+
});
|
|
105
|
+
if (results.length >= limit)
|
|
106
|
+
break;
|
|
83
107
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (idx < 0)
|
|
89
|
-
continue;
|
|
90
|
-
results.push({
|
|
91
|
-
eventId: row.id,
|
|
92
|
-
sessionId: row.session_id,
|
|
93
|
-
workspaceId: row.workspace_id,
|
|
94
|
-
workspaceName: row.workspace_name,
|
|
95
|
-
archived: row.archived_at !== null,
|
|
96
|
-
type: row.type,
|
|
97
|
-
timestamp: row.created_at,
|
|
98
|
-
snippet: buildSnippet(text, idx, trimmed),
|
|
99
|
-
});
|
|
100
|
-
if (results.length >= limit)
|
|
108
|
+
if (rows.length < batchSize)
|
|
109
|
+
break;
|
|
110
|
+
const last = rows.at(-1);
|
|
111
|
+
if (!last)
|
|
101
112
|
break;
|
|
113
|
+
cursorCreatedAt = last.created_at;
|
|
114
|
+
cursorId = last.id;
|
|
102
115
|
}
|
|
103
116
|
return results;
|
|
104
117
|
}
|
|
@@ -109,16 +109,18 @@ export function handleConnection(ws) {
|
|
|
109
109
|
export function emit(workspaceId, type, payload, sessionId) {
|
|
110
110
|
const id = nanoid();
|
|
111
111
|
const createdAt = new Date().toISOString();
|
|
112
|
+
let replayable = false;
|
|
112
113
|
// Best-effort persist — don't let FK violation (deleted workspace) break the broadcast
|
|
113
114
|
try {
|
|
114
115
|
const db = getDb();
|
|
115
116
|
db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
|
|
117
|
+
replayable = true;
|
|
116
118
|
}
|
|
117
119
|
catch (err) {
|
|
118
120
|
console.error(`[websocket-service] Failed to persist event (workspace=${workspaceId}, type=${type}):`, err);
|
|
119
121
|
}
|
|
120
122
|
// Build the event object to send
|
|
121
|
-
const event = { id, workspaceId, type, payload, sessionId, createdAt };
|
|
123
|
+
const event = { id, workspaceId, type, payload, sessionId, createdAt, replayable };
|
|
122
124
|
const message = JSON.stringify(event);
|
|
123
125
|
// Broadcast to subscribed clients. Wrap `.send` in try/catch so a dropped
|
|
124
126
|
// client doesn't throw and abort delivery to the remaining subscribers.
|
|
@@ -145,7 +147,7 @@ export function emit(workspaceId, type, payload, sessionId) {
|
|
|
145
147
|
export function emitEphemeral(workspaceId, type, payload) {
|
|
146
148
|
const id = nanoid();
|
|
147
149
|
const createdAt = new Date().toISOString();
|
|
148
|
-
const event = { id, workspaceId, type, payload, createdAt };
|
|
150
|
+
const event = { id, workspaceId, type, payload, createdAt, replayable: false };
|
|
149
151
|
const message = JSON.stringify(event);
|
|
150
152
|
let sendErrorLogged = false;
|
|
151
153
|
for (const [ws, subs] of clients) {
|
|
@@ -188,12 +190,14 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
|
188
190
|
// up. This keeps first-paint fast on long-lived workspaces with tens of
|
|
189
191
|
// thousands of events without ever deleting anything from the DB.
|
|
190
192
|
const INITIAL_WINDOW = 300;
|
|
193
|
+
let mode = 'snapshot';
|
|
191
194
|
if (lastEventId) {
|
|
192
195
|
// Resume path: replay every event strictly after the cursor (delta
|
|
193
196
|
// since last seen). If the cursor is stale/unknown, fall back to the
|
|
194
197
|
// recent window rather than streaming the entire history.
|
|
195
198
|
const lastRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(lastEventId);
|
|
196
199
|
if (lastRow) {
|
|
200
|
+
mode = 'delta';
|
|
197
201
|
rows = db
|
|
198
202
|
.prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) AND rowid > ? ORDER BY rowid ASC`)
|
|
199
203
|
.all(...resolvedIds, lastRow.rowid);
|
|
@@ -232,9 +236,10 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
|
232
236
|
payload: parsedPayload,
|
|
233
237
|
sessionId: row.session_id ?? undefined,
|
|
234
238
|
createdAt: row.created_at,
|
|
239
|
+
replayable: true,
|
|
235
240
|
};
|
|
236
241
|
});
|
|
237
|
-
ws.send(JSON.stringify({ type: 'sync:response', payload: { events } }));
|
|
242
|
+
ws.send(JSON.stringify({ type: 'sync:response', payload: { events, mode } }));
|
|
238
243
|
}
|
|
239
244
|
// ── Utilities ──────────────────────────────────────────────────────────────────
|
|
240
245
|
/**
|
|
@@ -165,20 +165,29 @@ export function updateWorkspaceStatus(id, status) {
|
|
|
165
165
|
return getWorkspace(id);
|
|
166
166
|
}
|
|
167
167
|
const WORKSPACE_NAME_MAX_LENGTH = 200;
|
|
168
|
-
|
|
169
|
-
export function updateWorkspaceName(id, name) {
|
|
170
|
-
// Strip control characters (incl. newlines) and collapse whitespace
|
|
168
|
+
function sanitizeWorkspaceName(name) {
|
|
171
169
|
const noControl = Array.from(name, (c) => {
|
|
172
170
|
const code = c.charCodeAt(0);
|
|
173
171
|
return code < 32 || code === 127 ? ' ' : c;
|
|
174
172
|
}).join('');
|
|
175
173
|
const sanitized = noControl.replace(/\s+/g, ' ').trim();
|
|
176
|
-
if (!sanitized)
|
|
174
|
+
if (!sanitized)
|
|
177
175
|
throw new Error('Workspace name cannot be empty');
|
|
178
|
-
}
|
|
179
176
|
if (sanitized.length > WORKSPACE_NAME_MAX_LENGTH) {
|
|
180
177
|
throw new Error(`Workspace name cannot exceed ${WORKSPACE_NAME_MAX_LENGTH} characters`);
|
|
181
178
|
}
|
|
179
|
+
return sanitized;
|
|
180
|
+
}
|
|
181
|
+
function normalizeWorkspaceDescription(description) {
|
|
182
|
+
const trimmed = description == null ? null : description.trim();
|
|
183
|
+
if (trimmed !== null && trimmed.length > 200) {
|
|
184
|
+
throw new Error(`Description must be 200 characters or fewer (got ${trimmed.length})`);
|
|
185
|
+
}
|
|
186
|
+
return trimmed && trimmed.length > 0 ? trimmed : null;
|
|
187
|
+
}
|
|
188
|
+
/** Update a workspace's display name. */
|
|
189
|
+
export function updateWorkspaceName(id, name) {
|
|
190
|
+
const sanitized = sanitizeWorkspaceName(name);
|
|
182
191
|
const db = getDb();
|
|
183
192
|
const now = new Date().toISOString();
|
|
184
193
|
const result = db.prepare('UPDATE workspaces SET name = ?, updated_at = ? WHERE id = ?').run(sanitized, now, id);
|
|
@@ -311,11 +320,7 @@ export function updateAgentPermissionMode(id, mode) {
|
|
|
311
320
|
* does not exist.
|
|
312
321
|
*/
|
|
313
322
|
export function updateWorkspaceDescription(id, description) {
|
|
314
|
-
const
|
|
315
|
-
if (trimmed !== null && trimmed.length > 200) {
|
|
316
|
-
throw new Error(`Description must be 200 characters or fewer (got ${trimmed.length})`);
|
|
317
|
-
}
|
|
318
|
-
const stored = trimmed && trimmed.length > 0 ? trimmed : null;
|
|
323
|
+
const stored = normalizeWorkspaceDescription(description);
|
|
319
324
|
const db = getDb();
|
|
320
325
|
const result = db
|
|
321
326
|
.prepare('UPDATE workspaces SET description = ?, updated_at = ? WHERE id = ?')
|
|
@@ -326,6 +331,72 @@ export function updateWorkspaceDescription(id, description) {
|
|
|
326
331
|
emitEphemeral(id, 'workspace:description-updated', { description: stored });
|
|
327
332
|
return getWorkspace(id);
|
|
328
333
|
}
|
|
334
|
+
/** Validate and persist a multi-field API update with one atomic SQL statement. */
|
|
335
|
+
export function updateWorkspaceFields(id, fields) {
|
|
336
|
+
const workspace = getWorkspace(id);
|
|
337
|
+
if (!workspace)
|
|
338
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
339
|
+
const assignments = [];
|
|
340
|
+
const values = [];
|
|
341
|
+
if (fields.model !== undefined) {
|
|
342
|
+
if (typeof fields.model !== 'string' || !fields.model.trim())
|
|
343
|
+
throw new Error('model must be a non-empty string');
|
|
344
|
+
assignments.push('model = ?');
|
|
345
|
+
values.push(fields.model.trim());
|
|
346
|
+
}
|
|
347
|
+
if (fields.reasoningEffort !== undefined) {
|
|
348
|
+
if (typeof fields.reasoningEffort !== 'string' || !fields.reasoningEffort.trim()) {
|
|
349
|
+
throw new Error('reasoningEffort must be a non-empty string');
|
|
350
|
+
}
|
|
351
|
+
assignments.push('reasoning_effort = ?');
|
|
352
|
+
values.push(fields.reasoningEffort.trim());
|
|
353
|
+
}
|
|
354
|
+
if (fields.agentPermissionMode !== undefined) {
|
|
355
|
+
if (!['plan', 'bypass', 'strict', 'interactive'].includes(fields.agentPermissionMode)) {
|
|
356
|
+
throw new Error('Invalid agentPermissionMode');
|
|
357
|
+
}
|
|
358
|
+
const legacyMode = fields.agentPermissionMode === 'plan' ? 'plan' : 'auto-accept';
|
|
359
|
+
const legacyProfile = fields.agentPermissionMode === 'plan' ? 'bypass' : fields.agentPermissionMode;
|
|
360
|
+
assignments.push('agent_permission_mode = ?', 'permission_mode = ?', 'permission_profile = ?');
|
|
361
|
+
values.push(fields.agentPermissionMode, legacyMode, legacyProfile);
|
|
362
|
+
}
|
|
363
|
+
if (fields.status !== undefined) {
|
|
364
|
+
const allowed = VALID_TRANSITIONS[workspace.status];
|
|
365
|
+
if (!allowed || (fields.status !== workspace.status && !allowed.includes(fields.status))) {
|
|
366
|
+
throw new Error(`Invalid status transition from '${workspace.status}' to '${fields.status}'. Allowed: ${allowed?.join(', ') ?? ''}`);
|
|
367
|
+
}
|
|
368
|
+
assignments.push('status = ?');
|
|
369
|
+
values.push(fields.status);
|
|
370
|
+
}
|
|
371
|
+
if (fields.name !== undefined) {
|
|
372
|
+
if (typeof fields.name !== 'string')
|
|
373
|
+
throw new Error('name must be a string');
|
|
374
|
+
assignments.push('name = ?');
|
|
375
|
+
values.push(sanitizeWorkspaceName(fields.name));
|
|
376
|
+
}
|
|
377
|
+
const hasDescription = Object.hasOwn(fields, 'description');
|
|
378
|
+
let storedDescription;
|
|
379
|
+
if (hasDescription) {
|
|
380
|
+
if (fields.description !== null && typeof fields.description !== 'string') {
|
|
381
|
+
throw new Error('description must be a string or null');
|
|
382
|
+
}
|
|
383
|
+
storedDescription = normalizeWorkspaceDescription(fields.description ?? null);
|
|
384
|
+
assignments.push('description = ?');
|
|
385
|
+
values.push(storedDescription);
|
|
386
|
+
}
|
|
387
|
+
if (assignments.length === 0)
|
|
388
|
+
return workspace;
|
|
389
|
+
assignments.push('updated_at = ?');
|
|
390
|
+
values.push(new Date().toISOString(), id);
|
|
391
|
+
const result = getDb()
|
|
392
|
+
.prepare(`UPDATE workspaces SET ${assignments.join(', ')} WHERE id = ?`)
|
|
393
|
+
.run(...values);
|
|
394
|
+
if (result.changes === 0)
|
|
395
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
396
|
+
if (hasDescription)
|
|
397
|
+
emitEphemeral(id, 'workspace:description-updated', { description: storedDescription ?? null });
|
|
398
|
+
return getWorkspace(id);
|
|
399
|
+
}
|
|
329
400
|
/**
|
|
330
401
|
* Update a workspace's agent-side description (≤ 200 chars after trim).
|
|
331
402
|
* Empty string (after trim) or `null` clears the column.
|
|
@@ -24,7 +24,7 @@ export async function purgeWorktree(workspaceId) {
|
|
|
24
24
|
console.error(`[purge] stopAgent failed for '${workspace.name}':`, msg);
|
|
25
25
|
}
|
|
26
26
|
try {
|
|
27
|
-
devServerService.stopDevServer(workspaceId);
|
|
27
|
+
await devServerService.stopDevServer(workspaceId);
|
|
28
28
|
}
|
|
29
29
|
catch (err) {
|
|
30
30
|
const msg = err instanceof Error ? err.message : String(err);
|