@loicngr/kobo 1.7.13 → 1.7.15
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/CHANGELOG.md +25 -0
- package/README.md +81 -399
- package/dist/server/db/migrations.js +33 -0
- package/dist/server/index.js +21 -2
- package/dist/server/routes/changelog.js +47 -0
- package/dist/server/routes/export.js +71 -0
- package/dist/server/routes/fs.js +49 -0
- package/dist/server/routes/git.js +16 -1
- package/dist/server/routes/health.js +2 -1
- package/dist/server/routes/workspaces.js +107 -64
- package/dist/server/services/agent/orchestrator.js +10 -0
- package/dist/server/services/archive-script-service.js +65 -0
- package/dist/server/services/auto-loop-service.js +7 -0
- package/dist/server/services/cleanup-script-service.js +118 -0
- package/dist/server/services/settings-service.js +184 -2
- package/dist/server/services/setup-script-service.js +10 -81
- package/dist/server/utils/git-ops.js +30 -0
- package/dist/server/utils/paths.js +4 -0
- package/dist/server/utils/script-runner.js +96 -0
- package/package.json +2 -1
- package/src/client/dist/spa/assets/ActivityFeed-DU6lDEP0.js +8 -0
- package/src/client/dist/spa/assets/{ActivityFeed-WjiQ9716.css → ActivityFeed-yUMQhnW4.css} +1 -1
- package/src/client/dist/spa/assets/{ClosePopup-C5EF3QrA.js → ClosePopup-CxvZA3ft.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-CdZr7f3j.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-DgHjL4cZ.css +1 -0
- package/src/client/dist/spa/assets/{DiffViewer-hWxcG22N.js → DiffViewer-m801GPfI.js} +3 -3
- package/src/client/dist/spa/assets/HealthPage-z1uIOpYk.js +1 -0
- package/src/client/dist/spa/assets/{MainLayout-Bp0oVWa-.css → MainLayout-BJmBXwYn.css} +1 -1
- package/src/client/dist/spa/assets/MainLayout-oJdQ-QKM.js +37 -0
- package/src/client/dist/spa/assets/{QBadge-u0mEz_W1.js → QBadge-NEwszYs7.js} +1 -1
- package/src/client/dist/spa/assets/QBanner-Jsq4uJZs.js +1 -0
- package/src/client/dist/spa/assets/QBtn-CoU-UC_j.js +1 -0
- package/src/client/dist/spa/assets/{QCheckbox-skYuqkHX.js → QCheckbox-Cq2STfHp.js} +1 -1
- package/src/client/dist/spa/assets/{QChip-DSMeriN6.js → QChip-DnJyQVs2.js} +2 -2
- package/src/client/dist/spa/assets/QExpansionItem-BTd5m2yV.js +1 -0
- package/src/client/dist/spa/assets/QIcon-BmEX2rXO.js +1 -0
- package/src/client/dist/spa/assets/QInput-D0t39uK_.js +1 -0
- package/src/client/dist/spa/assets/{QItemLabel-B0tYxHQg.js → QItemLabel-Btqw0P7M.js} +1 -1
- package/src/client/dist/spa/assets/QItemSection-DRg-QuAD.js +1 -0
- package/src/client/dist/spa/assets/QList-B3TuWSqL.js +1 -0
- package/src/client/dist/spa/assets/QMenu-C2Wwwf2E.js +1 -0
- package/src/client/dist/spa/assets/QPage-DO_bQyV_.js +1 -0
- package/src/client/dist/spa/assets/QRadio-M9mC5jZy.js +1 -0
- package/src/client/dist/spa/assets/QScrollArea-A1wI0IXU.js +1 -0
- package/src/client/dist/spa/assets/QSpace-DONPiIes.js +1 -0
- package/src/client/dist/spa/assets/{QSpinnerDots-CluOpUgq.js → QSpinnerDots-DspFKwCZ.js} +1 -1
- package/src/client/dist/spa/assets/{QToggle-aBvIHg6j.js → QToggle-DBzTAIbK.js} +1 -1
- package/src/client/dist/spa/assets/QTooltip-Bfdmzm_m.js +1 -0
- package/src/client/dist/spa/assets/SearchPage-ChmKHNKn.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-B59LoCos.js +9 -0
- package/src/client/dist/spa/assets/{SettingsPage-B_qPRSDH.css → SettingsPage-BJLyYrBN.css} +1 -1
- package/src/client/dist/spa/assets/{TouchPan-DPBZDRzA.js → TouchPan-BIE5rs7U.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-Bj1PJSWT.js +4 -0
- package/src/client/dist/spa/assets/WorkspacePage-tFBswKV9.css +1 -0
- package/src/client/dist/spa/assets/build-path-tree-BGUV3nY1.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-DTibHxcy.js → cssMode-BU4X8R6a.js} +1 -1
- package/src/client/dist/spa/assets/documents-B3nitIYF.js +1 -0
- package/src/client/dist/spa/assets/{editor.api-CdLzoLLv.js → editor.api-B4xBDzmJ.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-DSLdinVQ.js → editor.main-CSZRkloL.js} +3 -3
- package/src/client/dist/spa/assets/engineFeatures-CLOVr5b4.js +1 -0
- package/src/client/dist/spa/assets/expand-template-BxUkuL5g.js +1 -0
- package/src/client/dist/spa/assets/{formatters-CgfY9uSI.js → formatters-9dcj2tyJ.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-wf8G5c3b.js → freemarker2-DRz20wAV.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BGPZbdOB.js → handlebars-C0dsvPnC.js} +1 -1
- package/src/client/dist/spa/assets/{html-DPEVCP74.js → html-Cqvj1pWs.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-DMWUNRWz.js → htmlMode-BTHNvkm6.js} +1 -1
- package/src/client/dist/spa/assets/i18n-D1I-Us2H.js +1 -0
- package/src/client/dist/spa/assets/{index-5ydpLSpt.css → index-ClabzOPc.css} +1 -1
- package/src/client/dist/spa/assets/index-KABmOIkF.js +2 -0
- package/src/client/dist/spa/assets/{javascript-hOcrXbaP.js → javascript-C8n3U02v.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-EeNIyfJU.js → jsonMode-C3AFxQ6K.js} +1 -1
- package/src/client/dist/spa/assets/{kobo-commands-Bh5k0Smw.js → kobo-commands-BuxgteGZ.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-Cvw1gFPR.js → liquid-C4wtUDrJ.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-CGtsv5px.js → mdx-CaT1p1F2.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-RY1eW2u5.js → monaco.contribution-CJg5GKVf.js} +2 -2
- package/src/client/dist/spa/assets/notifications-BC6en6Lt.js +1 -0
- package/src/client/dist/spa/assets/permissionModes-BQHBTBwa.js +1 -0
- package/src/client/dist/spa/assets/{python-t0GNay3W.js → python-Cj54W2Tg.js} +1 -1
- package/src/client/dist/spa/assets/{razor-v9eEhqO6.js → razor-D3gJxoX_.js} +1 -1
- package/src/client/dist/spa/assets/render-chat-markdown-DxEHr3lW.js +60 -0
- package/src/client/dist/spa/assets/runtime-core.esm-bundler-D_RRiKBh.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-B376_f_2.js → tsMode-B6S4PLWH.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-WTvQ8gc0.js → typescript-Ca8AEX3t.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-DnSuDqo2.js +1 -0
- package/src/client/dist/spa/assets/use-id-BCnfiBjU.js +1 -0
- package/src/client/dist/spa/assets/use-onboarding-B3l7mx48.css +1 -0
- package/src/client/dist/spa/assets/use-onboarding-CNeLPDtv.js +2 -0
- package/src/client/dist/spa/assets/use-quasar-k24tGxE-.js +1 -0
- package/src/client/dist/spa/assets/{vue-i18n-cyuEW5NY.js → vue-i18n-Cq-KgjJC.js} +2 -2
- package/src/client/dist/spa/assets/{xml-BMuHSj3C.js → xml-CsKo4k8C.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-BRigOMm_.js → yaml-X5yKmi6z.js} +1 -1
- package/src/client/dist/spa/index.html +13 -13
- package/src/client/dist/spa/assets/ActivityFeed-CrlJXGsr.js +0 -8
- package/src/client/dist/spa/assets/CreatePage-B7DVVS0u.js +0 -2
- package/src/client/dist/spa/assets/CreatePage-ZyBHUbl0.css +0 -1
- package/src/client/dist/spa/assets/HealthPage-BP_TiWeV.js +0 -1
- package/src/client/dist/spa/assets/MainLayout-BRV8i-6o.js +0 -37
- package/src/client/dist/spa/assets/QBtn-CLU6snCm.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-BFqbf9C7.js +0 -1
- package/src/client/dist/spa/assets/QIcon-0rjEivgj.js +0 -1
- package/src/client/dist/spa/assets/QInput-Ciqjq5-e.js +0 -1
- package/src/client/dist/spa/assets/QItemSection-DZnyqPM3.js +0 -1
- package/src/client/dist/spa/assets/QList-MfhZa-uv.js +0 -1
- package/src/client/dist/spa/assets/QMenu-Cx3v9_6z.js +0 -1
- package/src/client/dist/spa/assets/QPage-CGYPttdA.js +0 -1
- package/src/client/dist/spa/assets/QRadio-BEsMlsx1.js +0 -1
- package/src/client/dist/spa/assets/QSpace-BrtkvWzZ.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-DdpeVKGV.js +0 -1
- package/src/client/dist/spa/assets/SearchPage-MfSrrMo1.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CR3kXpHd.js +0 -9
- package/src/client/dist/spa/assets/WorkspacePage-B4YnZ6re.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-DUKKBulA.js +0 -4
- package/src/client/dist/spa/assets/build-path-tree-DOtGuDWX.js +0 -1
- package/src/client/dist/spa/assets/documents-qOarUoMj.js +0 -1
- package/src/client/dist/spa/assets/engineFeatures-DNZNjYIf.js +0 -1
- package/src/client/dist/spa/assets/expand-template-Fy2sU7vj.js +0 -1
- package/src/client/dist/spa/assets/i18n-CUkOJRYM.js +0 -1
- package/src/client/dist/spa/assets/index-D9yyMIdQ.js +0 -2
- package/src/client/dist/spa/assets/notifications-BzyK_kYE.js +0 -1
- package/src/client/dist/spa/assets/permissionModes-BBA8zBcf.js +0 -1
- package/src/client/dist/spa/assets/project-color-d_h7ZYoM.js +0 -1
- package/src/client/dist/spa/assets/purify.es-CxaFgMzS.js +0 -60
- package/src/client/dist/spa/assets/render-chat-markdown-B41YA3kF.js +0 -1
- package/src/client/dist/spa/assets/runtime-core.esm-bundler-DPcTPMmX.js +0 -1
- package/src/client/dist/spa/assets/use-checkbox-DzLCp4E3.js +0 -1
- package/src/client/dist/spa/assets/use-id-BQW6DfJU.js +0 -1
- package/src/client/dist/spa/assets/use-quasar-C5gKpYwL.js +0 -1
|
@@ -332,6 +332,39 @@ export function runMigrations(db) {
|
|
|
332
332
|
db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, new Date().toISOString());
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
|
+
// Soft downgrade guard: if the database carries a migration version this
|
|
336
|
+
// build doesn't know about, it was migrated by a newer Kōbō. Forward-only
|
|
337
|
+
// migrations have no `down` step — warn loudly but let the app continue.
|
|
338
|
+
const maxVersion = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get().v ?? 0;
|
|
339
|
+
if (maxVersion > SCHEMA_VERSION) {
|
|
340
|
+
console.warn(`[kobo] ⚠ Database schema version (${maxVersion}) is higher than this Kōbō build supports ` +
|
|
341
|
+
`(${SCHEMA_VERSION}). The database was migrated by a newer version of Kōbō — running an older ` +
|
|
342
|
+
`build may cause errors. Update Kōbō, or restore a pre-downgrade backup from the Kōbō home directory.`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Return the versions of registered migrations not yet applied to `db`, without
|
|
347
|
+
* applying them. Used to decide whether a pre-migration backup is warranted.
|
|
348
|
+
* Returns `[]` for a fresh / uninitialised database — there is no data at risk.
|
|
349
|
+
*/
|
|
350
|
+
export function getPendingMigrations(db) {
|
|
351
|
+
const tableExists = (name) => db.prepare("SELECT count(*) AS c FROM sqlite_master WHERE type='table' AND name=?").get(name).c > 0;
|
|
352
|
+
let applied;
|
|
353
|
+
if (tableExists('schema_migrations')) {
|
|
354
|
+
applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
|
|
355
|
+
}
|
|
356
|
+
else if (tableExists('schema_version')) {
|
|
357
|
+
// Legacy single-row table: every version up to the stored one is applied.
|
|
358
|
+
const legacy = db.prepare('SELECT version FROM schema_version LIMIT 1').get()?.version ?? 0;
|
|
359
|
+
applied = new Set();
|
|
360
|
+
for (let v = 1; v <= legacy; v++)
|
|
361
|
+
applied.add(v);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
// Fresh / uninitialised DB — initSchema bootstraps it, nothing to back up.
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
return migrations.filter((m) => !applied.has(m.version)).map((m) => m.version);
|
|
335
368
|
}
|
|
336
369
|
/** Return the full migration history (for diagnostics / admin UI). */
|
|
337
370
|
export function getMigrationHistory(db) {
|
package/dist/server/index.js
CHANGED
|
@@ -5,10 +5,13 @@ import { serve } from '@hono/node-server';
|
|
|
5
5
|
import { Hono } from 'hono';
|
|
6
6
|
import WebSocket, { WebSocketServer } from 'ws';
|
|
7
7
|
import { closeDb, getDb } from './db/index.js';
|
|
8
|
-
import { runMigrations } from './db/migrations.js';
|
|
8
|
+
import { getPendingMigrations, runMigrations } from './db/migrations.js';
|
|
9
|
+
import changelogRouter from './routes/changelog.js';
|
|
9
10
|
import devServerRouter from './routes/dev-server.js';
|
|
10
11
|
import documentsRouter from './routes/documents.js';
|
|
11
12
|
import { enginesRouter } from './routes/engines.js';
|
|
13
|
+
import exportRouter from './routes/export.js';
|
|
14
|
+
import fsRouter from './routes/fs.js';
|
|
12
15
|
import gitRouter from './routes/git.js';
|
|
13
16
|
import healthRouter from './routes/health.js';
|
|
14
17
|
import imagesRouter from './routes/images.js';
|
|
@@ -25,7 +28,7 @@ import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb,
|
|
|
25
28
|
import * as autoLoopService from './services/auto-loop-service.js';
|
|
26
29
|
import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
|
|
27
30
|
import * as cronService from './services/cron-service.js';
|
|
28
|
-
import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
|
|
31
|
+
import { createDailyDbBackupIfNeeded, createPreMigrationBackup } from './services/db-backup-service.js';
|
|
29
32
|
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
30
33
|
import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
|
|
31
34
|
import * as quotaBackoffService from './services/quota-backoff-service.js';
|
|
@@ -39,6 +42,19 @@ import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/
|
|
|
39
42
|
console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
|
|
40
43
|
// Initialize DB + run migrations
|
|
41
44
|
const db = getDb();
|
|
45
|
+
// Pre-migration backup: snapshot the DB before applying any pending schema
|
|
46
|
+
// migration so a botched upgrade can be rolled back manually. Best-effort —
|
|
47
|
+
// a backup failure must not block boot (the daily backup is a second net).
|
|
48
|
+
try {
|
|
49
|
+
const pending = getPendingMigrations(db);
|
|
50
|
+
if (pending.length > 0) {
|
|
51
|
+
const result = await createPreMigrationBackup(db, getDbPath(), `v${pending[pending.length - 1]}`);
|
|
52
|
+
console.log(`[kobo] Pre-migration backup before applying ${pending.length} migration(s): ${result.created}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.error('[kobo] Pre-migration backup failed (continuing — daily backup remains as fallback):', err);
|
|
57
|
+
}
|
|
42
58
|
runMigrations(db);
|
|
43
59
|
// Daily DB backup (best-effort, fire-and-forget — never blocks boot).
|
|
44
60
|
// Creates a WAL-safe snapshot alongside kobo.db if no backup exists in the
|
|
@@ -75,11 +91,14 @@ app.route('/api/workspaces', imagesRouter);
|
|
|
75
91
|
app.route('/api/notion', notionRouter);
|
|
76
92
|
app.route('/api/sentry', sentryRouter);
|
|
77
93
|
app.route('/api/git', gitRouter);
|
|
94
|
+
app.route('/api/fs', fsRouter);
|
|
95
|
+
app.route('/api/changelog', changelogRouter);
|
|
78
96
|
app.route('/api/settings', settingsRouter);
|
|
79
97
|
app.route('/api/dev-server', devServerRouter);
|
|
80
98
|
app.route('/api/templates', templatesRouter);
|
|
81
99
|
app.route('/api/usage', usageRoutes);
|
|
82
100
|
app.route('/api/workspaces', documentsRouter);
|
|
101
|
+
app.route('/api/workspaces', exportRouter);
|
|
83
102
|
app.route('/api/search', searchRouter);
|
|
84
103
|
app.route('/api/health', healthRouter);
|
|
85
104
|
app.route('/api/engines', enginesRouter);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { getChangelogPath, getPackageVersion } from '../utils/paths.js';
|
|
4
|
+
/** Hono sub-router for the in-app "What's new" dialog. */
|
|
5
|
+
const app = new Hono();
|
|
6
|
+
/**
|
|
7
|
+
* Parse a Keep-a-Changelog markdown file into ordered version sections. Each
|
|
8
|
+
* `## <version>` heading starts a new entry; everything until the next heading
|
|
9
|
+
* is its notes. A leading `v` on the version is stripped.
|
|
10
|
+
*/
|
|
11
|
+
export function parseChangelog(markdown) {
|
|
12
|
+
const entries = [];
|
|
13
|
+
let current = null;
|
|
14
|
+
for (const line of markdown.split('\n')) {
|
|
15
|
+
const heading = line.match(/^##\s+v?(\d+\.\d+\.\d+[\w.-]*)\s*$/);
|
|
16
|
+
if (heading) {
|
|
17
|
+
if (current)
|
|
18
|
+
entries.push({ version: current.version, notes: current.lines.join('\n').trim() });
|
|
19
|
+
current = { version: heading[1], lines: [] };
|
|
20
|
+
}
|
|
21
|
+
else if (current) {
|
|
22
|
+
current.lines.push(line);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (current)
|
|
26
|
+
entries.push({ version: current.version, notes: current.lines.join('\n').trim() });
|
|
27
|
+
return entries;
|
|
28
|
+
}
|
|
29
|
+
// GET /api/changelog — current app version + parsed CHANGELOG.md sections,
|
|
30
|
+
// powering the "What's new" dialog shown after an update.
|
|
31
|
+
app.get('/', (c) => {
|
|
32
|
+
try {
|
|
33
|
+
let versions = [];
|
|
34
|
+
try {
|
|
35
|
+
versions = parseChangelog(fs.readFileSync(getChangelogPath(), 'utf-8'));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// No CHANGELOG.md shipped (or unreadable) — degrade to an empty list.
|
|
39
|
+
}
|
|
40
|
+
return c.json({ currentVersion: getPackageVersion(), versions });
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
return c.json({ error: message }, 500);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
export default app;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
import * as workspaceService from '../services/workspace-service.js';
|
|
4
|
+
/** Hono sub-router for workspace data exports (mounted on /api/workspaces). */
|
|
5
|
+
const app = new Hono();
|
|
6
|
+
/** Quote a CSV cell when it contains a delimiter, quote, or newline (RFC 4180). */
|
|
7
|
+
function csvCell(value) {
|
|
8
|
+
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
9
|
+
}
|
|
10
|
+
function csvRow(values) {
|
|
11
|
+
return values.map(csvCell).join(',');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Best-effort extraction of a human-readable text from an event payload.
|
|
15
|
+
* Different event types carry their text under different keys; the full
|
|
16
|
+
* payload is exported alongside, so this is a convenience column only.
|
|
17
|
+
*/
|
|
18
|
+
function extractText(payload) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(payload);
|
|
21
|
+
if (!parsed || typeof parsed !== 'object')
|
|
22
|
+
return '';
|
|
23
|
+
const obj = parsed;
|
|
24
|
+
for (const key of ['text', 'content', 'message']) {
|
|
25
|
+
const v = obj[key];
|
|
26
|
+
if (typeof v === 'string')
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* malformed payload — leave the text column empty */
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
/** Filesystem-safe slug for the download filename. */
|
|
36
|
+
function slugify(name) {
|
|
37
|
+
const slug = name
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
40
|
+
.replace(/^-+|-+$/g, '');
|
|
41
|
+
return slug || 'workspace';
|
|
42
|
+
}
|
|
43
|
+
// GET /api/workspaces/:id/events.csv — export every ws_event of the workspace
|
|
44
|
+
// (all sessions, chronological) as a CSV file.
|
|
45
|
+
app.get('/:id/events.csv', (c) => {
|
|
46
|
+
try {
|
|
47
|
+
const id = c.req.param('id');
|
|
48
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
49
|
+
if (!workspace) {
|
|
50
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
51
|
+
}
|
|
52
|
+
const rows = getDb()
|
|
53
|
+
.prepare('SELECT session_id, type, payload, created_at FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC')
|
|
54
|
+
.all(id);
|
|
55
|
+
const lines = [csvRow(['created_at', 'session_id', 'type', 'text', 'payload'])];
|
|
56
|
+
for (const r of rows) {
|
|
57
|
+
lines.push(csvRow([r.created_at, r.session_id ?? '', r.type, extractText(r.payload), r.payload]));
|
|
58
|
+
}
|
|
59
|
+
// Leading BOM so Excel reads UTF-8 correctly; CRLF line endings per RFC 4180.
|
|
60
|
+
const csv = `${lines.join('\r\n')}\r\n`;
|
|
61
|
+
return c.body(csv, 200, {
|
|
62
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
63
|
+
'Content-Disposition': `attachment; filename="${slugify(workspace.name)}-events.csv"`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
return c.json({ error: message }, 500);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
export default app;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Hono } from 'hono';
|
|
5
|
+
/** Hono sub-router for local filesystem browsing (project folder picker). */
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
// GET /api/fs/list-dirs?path=<dir> — list the immediate subdirectories of a
|
|
8
|
+
// directory. Defaults to the user's home directory when `path` is omitted.
|
|
9
|
+
// Local single-user dev tool: the user browses their own filesystem.
|
|
10
|
+
app.get('/list-dirs', (c) => {
|
|
11
|
+
try {
|
|
12
|
+
const requested = c.req.query('path');
|
|
13
|
+
const target = path.resolve(requested?.trim() ? requested : os.homedir());
|
|
14
|
+
let stat;
|
|
15
|
+
try {
|
|
16
|
+
stat = fs.statSync(target);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return c.json({ error: `Directory not found: ${target}` }, 404);
|
|
20
|
+
}
|
|
21
|
+
if (!stat.isDirectory()) {
|
|
22
|
+
return c.json({ error: `Not a directory: ${target}` }, 400);
|
|
23
|
+
}
|
|
24
|
+
let dirents;
|
|
25
|
+
try {
|
|
26
|
+
dirents = fs.readdirSync(target, { withFileTypes: true });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
30
|
+
return c.json({ error: `Cannot read directory: ${message}` }, 403);
|
|
31
|
+
}
|
|
32
|
+
// Directories only, hidden ones excluded, sorted case-insensitively.
|
|
33
|
+
const entries = dirents
|
|
34
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
35
|
+
.map((d) => ({ name: d.name, path: path.join(target, d.name) }))
|
|
36
|
+
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
37
|
+
const parent = path.dirname(target);
|
|
38
|
+
return c.json({
|
|
39
|
+
path: target,
|
|
40
|
+
parent: parent === target ? null : parent,
|
|
41
|
+
entries,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
46
|
+
return c.json({ error: message }, 500);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
export default app;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { getDb } from '../db/index.js';
|
|
3
3
|
import { listOrphanWorktrees } from '../services/worktree-service.js';
|
|
4
|
-
import { listBranches, listRemoteBranches } from '../utils/git-ops.js';
|
|
4
|
+
import { listBranches, listRemoteBranches, listWorktreeFiles } from '../utils/git-ops.js';
|
|
5
5
|
/** Hono sub-router for git-related endpoints (branch listing). */
|
|
6
6
|
const app = new Hono();
|
|
7
7
|
// GET /api/git/branches?path=<repoPath> — list branches for a repo
|
|
@@ -20,6 +20,21 @@ app.get('/branches', (c) => {
|
|
|
20
20
|
return c.json({ error: message }, 500);
|
|
21
21
|
}
|
|
22
22
|
});
|
|
23
|
+
// GET /api/git/files?path=<worktreePath> — list a worktree's files (tracked +
|
|
24
|
+
// untracked-but-not-ignored) for the chat's `@file` autocomplete.
|
|
25
|
+
app.get('/files', (c) => {
|
|
26
|
+
try {
|
|
27
|
+
const repoPath = c.req.query('path');
|
|
28
|
+
if (!repoPath) {
|
|
29
|
+
return c.json({ error: 'Missing required query parameter: path' }, 400);
|
|
30
|
+
}
|
|
31
|
+
return c.json({ files: listWorktreeFiles(repoPath) });
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
35
|
+
return c.json({ error: message }, 500);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
23
38
|
// GET /api/git/orphan-worktrees?projectPath=<path> — list worktrees of a
|
|
24
39
|
// project that are NOT attached to any Kōbō workspace yet.
|
|
25
40
|
app.get('/orphan-worktrees', (c) => {
|
|
@@ -5,7 +5,7 @@ import { getDb } from '../db/index.js';
|
|
|
5
5
|
import { SCHEMA_VERSION } from '../db/migrations.js';
|
|
6
6
|
import { resolveCodexBinary } from '../services/agent/engines/codex/spawn.js';
|
|
7
7
|
import { getGlobalSettings, getProjectSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
|
|
8
|
-
import { getDbPath, getKoboHome } from '../utils/paths.js';
|
|
8
|
+
import { getDbPath, getKoboHome, getPackageVersion } from '../utils/paths.js';
|
|
9
9
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
10
10
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
11
11
|
const app = new Hono();
|
|
@@ -146,6 +146,7 @@ app.get('/report', (c) => {
|
|
|
146
146
|
const settingsRow = db.prepare('SELECT COUNT(*) as n FROM workspaces').get();
|
|
147
147
|
const archivedRow = db.prepare('SELECT COUNT(*) as n FROM workspaces WHERE archived_at IS NOT NULL').get();
|
|
148
148
|
const report = {
|
|
149
|
+
version: getPackageVersion(),
|
|
149
150
|
koboHome: home,
|
|
150
151
|
db: {
|
|
151
152
|
path: dbPath,
|
|
@@ -9,6 +9,7 @@ import { getDb } from '../db/index.js';
|
|
|
9
9
|
import { migrationGuard } from '../middleware/migration-guard.js';
|
|
10
10
|
import { listEngines } from '../services/agent/engines/registry.js';
|
|
11
11
|
import * as agentManager from '../services/agent/orchestrator.js';
|
|
12
|
+
import * as archiveScriptService from '../services/archive-script-service.js';
|
|
12
13
|
import * as autoLoopService from '../services/auto-loop-service.js';
|
|
13
14
|
import * as cronService from '../services/cron-service.js';
|
|
14
15
|
import * as devServerService from '../services/dev-server-service.js';
|
|
@@ -1694,6 +1695,8 @@ app.post('/:id/archive', migrationGuard, (c) => {
|
|
|
1694
1695
|
}
|
|
1695
1696
|
const updated = workspaceService.archiveWorkspace(id);
|
|
1696
1697
|
wsService.emitEphemeral(id, 'workspace:archived', { workspace: updated });
|
|
1698
|
+
// Run the project's archive script (best-effort — never blocks the archive).
|
|
1699
|
+
archiveScriptService.onWorkspaceArchived(id);
|
|
1697
1700
|
return c.json(updated);
|
|
1698
1701
|
}
|
|
1699
1702
|
catch (err) {
|
|
@@ -1721,86 +1724,126 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
|
1721
1724
|
return c.json({ error: message }, 500);
|
|
1722
1725
|
}
|
|
1723
1726
|
});
|
|
1724
|
-
//
|
|
1725
|
-
|
|
1727
|
+
// Shared teardown for a single workspace: stops the agent, destroys the
|
|
1728
|
+
// terminal, removes the owned worktree, optionally deletes local/remote
|
|
1729
|
+
// branches, then deletes the DB row (cascades to tasks/sessions/events).
|
|
1730
|
+
// Every side-effect is best-effort — failures are collected as warnings
|
|
1731
|
+
// rather than thrown, so a bulk delete never aborts mid-batch. Returns the
|
|
1732
|
+
// list of user-facing warning messages (empty when everything was clean).
|
|
1733
|
+
function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
1734
|
+
// Stop agent if running (best-effort)
|
|
1726
1735
|
try {
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
//
|
|
1736
|
+
agentManager.stopAgent(workspace.id);
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
// Agent may not be running — ignore
|
|
1740
|
+
}
|
|
1741
|
+
try {
|
|
1742
|
+
terminalService.destroyTerminal(workspace.id);
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
// Terminal may not exist — ignore
|
|
1746
|
+
}
|
|
1747
|
+
// Collected best-effort warnings: the DB deletion always proceeds, but
|
|
1748
|
+
// side-effects (worktree, local/remote branches) can fail independently.
|
|
1749
|
+
// We surface a user-friendly message per failure so the UI can show a
|
|
1750
|
+
// sticky toast with a copy-pasteable recovery command — common case:
|
|
1751
|
+
// Docker leaves root-owned files inside the worktree, git worktree
|
|
1752
|
+
// remove fails with EACCES.
|
|
1753
|
+
const warnings = [];
|
|
1754
|
+
// Remove worktree (only if owned — for attached external worktrees we
|
|
1755
|
+
// never created the dir, so we must not delete it on the user's behalf).
|
|
1756
|
+
const worktreePath = workspace.worktreePath;
|
|
1757
|
+
if (workspace.worktreeOwned) {
|
|
1737
1758
|
try {
|
|
1738
|
-
|
|
1759
|
+
worktreeService.removeWorktree(workspace.projectPath, worktreePath);
|
|
1739
1760
|
}
|
|
1740
|
-
catch {
|
|
1741
|
-
|
|
1761
|
+
catch (err) {
|
|
1762
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1763
|
+
console.error(`[workspaces] Failed to remove worktree: ${message}`);
|
|
1764
|
+
warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
|
|
1765
|
+
`Fix manually:\n` +
|
|
1766
|
+
` sudo rm -rf '${worktreePath}'\n` +
|
|
1767
|
+
` cd '${workspace.projectPath}' && git worktree prune\n` +
|
|
1768
|
+
`Reason: ${message}`);
|
|
1742
1769
|
}
|
|
1770
|
+
}
|
|
1771
|
+
else {
|
|
1772
|
+
console.log(`[workspaces] keeping reused worktree on delete: ${worktreePath}`);
|
|
1773
|
+
}
|
|
1774
|
+
// Delete local branch if requested
|
|
1775
|
+
if (opts.deleteLocalBranch) {
|
|
1743
1776
|
try {
|
|
1744
|
-
|
|
1777
|
+
gitOps.deleteLocalBranch(workspace.projectPath, workspace.workingBranch);
|
|
1745
1778
|
}
|
|
1746
|
-
catch {
|
|
1747
|
-
|
|
1779
|
+
catch (err) {
|
|
1780
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1781
|
+
console.error(`[workspaces] Failed to delete local branch: ${message}`);
|
|
1782
|
+
warnings.push(`Failed to delete local branch '${workspace.workingBranch}'. Fix manually:\n` +
|
|
1783
|
+
` cd '${workspace.projectPath}' && git branch -D '${workspace.workingBranch}'\n` +
|
|
1784
|
+
`Reason: ${message}`);
|
|
1748
1785
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
// remove fails with EACCES.
|
|
1755
|
-
const warnings = [];
|
|
1756
|
-
// Remove worktree (only if owned — for attached external worktrees we
|
|
1757
|
-
// never created the dir, so we must not delete it on the user's behalf).
|
|
1758
|
-
const worktreePath = workspace.worktreePath;
|
|
1759
|
-
if (workspace.worktreeOwned) {
|
|
1760
|
-
try {
|
|
1761
|
-
worktreeService.removeWorktree(workspace.projectPath, worktreePath);
|
|
1762
|
-
}
|
|
1763
|
-
catch (err) {
|
|
1764
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1765
|
-
console.error(`[workspaces] Failed to remove worktree: ${message}`);
|
|
1766
|
-
warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
|
|
1767
|
-
`Fix manually:\n` +
|
|
1768
|
-
` sudo rm -rf '${worktreePath}'\n` +
|
|
1769
|
-
` cd '${workspace.projectPath}' && git worktree prune\n` +
|
|
1770
|
-
`Reason: ${message}`);
|
|
1771
|
-
}
|
|
1786
|
+
}
|
|
1787
|
+
// Delete remote branch if requested
|
|
1788
|
+
if (opts.deleteRemoteBranch) {
|
|
1789
|
+
try {
|
|
1790
|
+
gitOps.deleteRemoteBranch(workspace.projectPath, workspace.workingBranch);
|
|
1772
1791
|
}
|
|
1773
|
-
|
|
1774
|
-
|
|
1792
|
+
catch (err) {
|
|
1793
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1794
|
+
console.error(`[workspaces] Failed to delete remote branch: ${message}`);
|
|
1795
|
+
warnings.push(`Failed to delete remote branch '${workspace.workingBranch}'. Fix manually:\n` +
|
|
1796
|
+
` cd '${workspace.projectPath}' && git push origin --delete '${workspace.workingBranch}'\n` +
|
|
1797
|
+
`Reason: ${message}`);
|
|
1775
1798
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1799
|
+
}
|
|
1800
|
+
// Delete workspace from DB (cascades to tasks, sessions, events)
|
|
1801
|
+
workspaceService.deleteWorkspace(workspace.id);
|
|
1802
|
+
return warnings;
|
|
1803
|
+
}
|
|
1804
|
+
// DELETE /api/workspaces/archived — bulk-delete every archived workspace.
|
|
1805
|
+
// Must be declared BEFORE `DELETE /:id` or the dynamic segment captures it.
|
|
1806
|
+
// Each workspace teardown is isolated: an error on one is swallowed into the
|
|
1807
|
+
// warnings list and never aborts the batch.
|
|
1808
|
+
app.delete('/archived', migrationGuard, async (c) => {
|
|
1809
|
+
try {
|
|
1810
|
+
const body = await c.req
|
|
1811
|
+
.json()
|
|
1812
|
+
.catch(() => ({}));
|
|
1813
|
+
const archived = workspaceService.listArchivedWorkspaces();
|
|
1814
|
+
const warnings = [];
|
|
1815
|
+
let deleted = 0;
|
|
1816
|
+
for (const workspace of archived) {
|
|
1778
1817
|
try {
|
|
1779
|
-
|
|
1818
|
+
warnings.push(...deleteWorkspaceWithSideEffects(workspace, body));
|
|
1819
|
+
deleted++;
|
|
1780
1820
|
}
|
|
1781
1821
|
catch (err) {
|
|
1782
1822
|
const message = err instanceof Error ? err.message : String(err);
|
|
1783
|
-
console.error(`[workspaces] Failed to delete
|
|
1784
|
-
warnings.push(`Failed to delete
|
|
1785
|
-
` cd '${workspace.projectPath}' && git branch -D '${workspace.workingBranch}'\n` +
|
|
1786
|
-
`Reason: ${message}`);
|
|
1823
|
+
console.error(`[workspaces] Failed to delete archived workspace '${workspace.id}': ${message}`);
|
|
1824
|
+
warnings.push(`Failed to delete workspace '${workspace.name}': ${message}`);
|
|
1787
1825
|
}
|
|
1788
1826
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1827
|
+
return c.json({ ok: true, deleted, warnings }, 200);
|
|
1828
|
+
}
|
|
1829
|
+
catch (err) {
|
|
1830
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1831
|
+
return c.json({ error: message }, 500);
|
|
1832
|
+
}
|
|
1833
|
+
});
|
|
1834
|
+
// DELETE /api/workspaces/:id — delete workspace
|
|
1835
|
+
app.delete('/:id', migrationGuard, async (c) => {
|
|
1836
|
+
try {
|
|
1837
|
+
const id = c.req.param('id');
|
|
1838
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
1839
|
+
if (!workspace) {
|
|
1840
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1801
1841
|
}
|
|
1802
|
-
//
|
|
1803
|
-
|
|
1842
|
+
// Parse optional body for branch deletion options
|
|
1843
|
+
const body = await c.req
|
|
1844
|
+
.json()
|
|
1845
|
+
.catch(() => ({}));
|
|
1846
|
+
const warnings = deleteWorkspaceWithSideEffects(workspace, body);
|
|
1804
1847
|
// When everything worked cleanly we keep the legacy 204 response so
|
|
1805
1848
|
// existing clients aren't surprised by a JSON body. Warnings promote the
|
|
1806
1849
|
// response to 200 so the body is readable.
|
|
@@ -4,6 +4,7 @@ import { getDb } from '../../db/index.js';
|
|
|
4
4
|
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
|
|
5
5
|
import { unregisterProcess } from '../../utils/process-tracker.js';
|
|
6
6
|
import * as autoLoopService from '../auto-loop-service.js';
|
|
7
|
+
import * as cleanupScriptService from '../cleanup-script-service.js';
|
|
7
8
|
import * as cronService from '../cron-service.js';
|
|
8
9
|
import * as quotaBackoffService from '../quota-backoff-service.js';
|
|
9
10
|
import { getEffectiveSettings } from '../settings-service.js';
|
|
@@ -308,6 +309,10 @@ function readEffectiveSettingsSafe(projectPath) {
|
|
|
308
309
|
sourceBranch: 'main',
|
|
309
310
|
devServer: null,
|
|
310
311
|
setupScript: '',
|
|
312
|
+
cleanupScript: '',
|
|
313
|
+
cleanupScriptMode: 'no-tasks',
|
|
314
|
+
cleanupScriptOnlyOnChanges: false,
|
|
315
|
+
archiveScript: '',
|
|
311
316
|
notionStatusProperty: '',
|
|
312
317
|
notionInProgressStatus: '',
|
|
313
318
|
};
|
|
@@ -531,7 +536,12 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
531
536
|
// cleared, next iteration will start fresh) — report 'completed' to
|
|
532
537
|
// auto-loop so it continues.
|
|
533
538
|
const effectiveReason = isResumeFailed ? 'completed' : ev.reason;
|
|
539
|
+
// Capture the auto-loop flag BEFORE autoLoopService.onSessionEnded —
|
|
540
|
+
// disable() clears it, and the cleanup hook needs to know whether this was
|
|
541
|
+
// a mid-loop session (never cleans) or a standalone one.
|
|
542
|
+
const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
|
|
534
543
|
autoLoopService.onSessionEnded(workspaceId, effectiveReason, delta);
|
|
544
|
+
cleanupScriptService.onSessionEnded(workspaceId, effectiveReason, { wasAutoLoop });
|
|
535
545
|
}
|
|
536
546
|
if (ev.kind === 'session:user-input-requested') {
|
|
537
547
|
if (ev.requestKind === 'question') {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
4
|
+
import { runScript, SCRIPT_TIMEOUT_MS } from '../utils/script-runner.js';
|
|
5
|
+
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
|
+
import { getEffectiveSettings, getGlobalSettings, getProjectSettings } from './settings-service.js';
|
|
7
|
+
function getRow(workspaceId) {
|
|
8
|
+
const db = getDb();
|
|
9
|
+
const row = db
|
|
10
|
+
.prepare(`SELECT id, name, project_path, working_branch, source_branch, worktree_path
|
|
11
|
+
FROM workspaces WHERE id = ?`)
|
|
12
|
+
.get(workspaceId);
|
|
13
|
+
return row ?? null;
|
|
14
|
+
}
|
|
15
|
+
/** Execute the archive script in a worktree, streaming `archive:*` WS events. */
|
|
16
|
+
export function runArchiveScript(workspaceId, worktreePath, script, env) {
|
|
17
|
+
return runScript({
|
|
18
|
+
workspaceId,
|
|
19
|
+
worktreePath,
|
|
20
|
+
script,
|
|
21
|
+
eventPrefix: 'archive',
|
|
22
|
+
tmpFileName: '.archive-script.tmp',
|
|
23
|
+
env,
|
|
24
|
+
timeoutMs: SCRIPT_TIMEOUT_MS,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Hook for the `POST /api/workspaces/:id/archive` route — runs the project's
|
|
29
|
+
* archive script (if configured) after a workspace is archived. The worktree is
|
|
30
|
+
* still on disk at archive time (archiving is a soft-delete). Best-effort: never
|
|
31
|
+
* blocks or fails the archive operation.
|
|
32
|
+
*/
|
|
33
|
+
export function onWorkspaceArchived(workspaceId) {
|
|
34
|
+
try {
|
|
35
|
+
const row = getRow(workspaceId);
|
|
36
|
+
if (!row)
|
|
37
|
+
return;
|
|
38
|
+
const effective = getEffectiveSettings(row.project_path);
|
|
39
|
+
const script = effective.archiveScript;
|
|
40
|
+
if (!script.trim())
|
|
41
|
+
return; // empty = disabled
|
|
42
|
+
const global = getGlobalSettings();
|
|
43
|
+
const projectSettings = getProjectSettings(row.project_path);
|
|
44
|
+
const projectSlug = global.worktreesPrefixByProject
|
|
45
|
+
? slugifyProjectName(projectSettings?.displayName ?? '', row.project_path)
|
|
46
|
+
: undefined;
|
|
47
|
+
const worktreePath = row.worktree_path ??
|
|
48
|
+
resolveWorkspaceWorktreePath(row.project_path, row.working_branch, global.worktreesPath, projectSlug);
|
|
49
|
+
if (!fs.existsSync(worktreePath)) {
|
|
50
|
+
console.warn(`[archive-script-service] worktree missing, skipping archive script: ${worktreePath}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
void runArchiveScript(workspaceId, worktreePath, script, {
|
|
54
|
+
workspaceName: row.name,
|
|
55
|
+
branchName: row.working_branch,
|
|
56
|
+
sourceBranch: row.source_branch,
|
|
57
|
+
projectPath: row.project_path,
|
|
58
|
+
}).catch((err) => {
|
|
59
|
+
console.error('[archive-script-service] runArchiveScript failed:', err);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error('[archive-script-service] onWorkspaceArchived failed:', err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -4,6 +4,7 @@ import { getDb } from '../db/index.js';
|
|
|
4
4
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
5
5
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
6
|
import * as orchestrator from './agent/orchestrator.js';
|
|
7
|
+
import * as cleanupScriptService from './cleanup-script-service.js';
|
|
7
8
|
import * as settingsService from './settings-service.js';
|
|
8
9
|
import { getSuitePrompts } from './skill-suite-prompts.js';
|
|
9
10
|
import { emit, emitEphemeral } from './websocket-service.js';
|
|
@@ -82,6 +83,12 @@ export function disable(workspaceId, reason) {
|
|
|
82
83
|
const db = getDb();
|
|
83
84
|
db.prepare('UPDATE workspaces SET auto_loop = 0 WHERE id = ?').run(workspaceId);
|
|
84
85
|
emitEphemeral(workspaceId, 'autoloop:disabled', { reason });
|
|
86
|
+
// The loop finished every task — run the project's cleanup script. Other
|
|
87
|
+
// disable reasons (stall / error / user-action) leave tasks unfinished, so
|
|
88
|
+
// they intentionally skip the cleanup.
|
|
89
|
+
if (reason === 'completed') {
|
|
90
|
+
cleanupScriptService.onAutoLoopCompleted(workspaceId);
|
|
91
|
+
}
|
|
85
92
|
}
|
|
86
93
|
/**
|
|
87
94
|
* Route a `session:ended` event into the auto-loop state machine.
|