@loicngr/kobo 1.3.0 → 1.4.1
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 +14 -0
- package/dist/mcp-server/kobo-tasks-server.js +2 -0
- package/dist/server/db/index.js +1 -0
- package/dist/server/db/migrations.js +11 -0
- package/dist/server/db/schema.js +4 -0
- package/dist/server/index.js +58 -7
- package/dist/server/routes/workspaces.js +183 -38
- package/dist/server/services/agent-manager.js +24 -6
- package/dist/server/services/notion-service.js +6 -3
- package/dist/server/services/pr-watcher-service.js +27 -6
- package/dist/server/services/settings-service.js +16 -0
- package/dist/server/services/setup-script-service.js +82 -0
- package/dist/server/services/websocket-service.js +41 -4
- package/dist/server/services/workspace-service.js +19 -3
- package/dist/server/utils/git-ops.js +172 -4
- package/dist/server/utils/paths.js +13 -0
- package/dist/server/utils/process-tracker.js +0 -4
- package/package.json +4 -3
- package/src/client/dist/spa/assets/ActivityFeed-BSxKJawc.js +60 -0
- package/src/client/dist/spa/assets/ActivityFeed-OvgJQL4-.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-Dqo_YW16.js +2 -0
- package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-DKAUYJDc.js +2 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-k1h7X_-h.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-B7du-70m.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CoAZ_DKt.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-D0406B4n.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-CnAg2DeQ.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-BG9VWE5v.woff +0 -0
- package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-nj_hK6r1.js +2 -0
- package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +1 -0
- package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-B-elXc8g.js +1 -0
- package/src/client/dist/spa/assets/QPage-I643-HUS.js +1 -0
- package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-BNA4jJYf.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-DNd8DeRS.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BVElLqkf.js +2 -0
- package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +1 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +1 -0
- package/src/client/dist/spa/assets/abap-Co3wj02O.js +1 -0
- package/src/client/dist/spa/assets/apex-CUKwGs62.js +1 -0
- package/src/client/dist/spa/assets/azcli-DMImymmY.js +1 -0
- package/src/client/dist/spa/assets/bat--P_y70-E.js +1 -0
- package/src/client/dist/spa/assets/bicep-C3w6oSfK.js +2 -0
- package/src/client/dist/spa/assets/cameligo-D9NSR4Rj.js +1 -0
- package/src/client/dist/spa/assets/clojure-BMcQme0t.js +1 -0
- package/src/client/dist/spa/assets/codicon-CgENjH2v.ttf +0 -0
- package/src/client/dist/spa/assets/coffee-BbMZaWx7.js +1 -0
- package/src/client/dist/spa/assets/cpp-CbrtEGgw.js +1 -0
- package/src/client/dist/spa/assets/csharp-Bc0fjUxA.js +1 -0
- package/src/client/dist/spa/assets/csp-DmbXuMT0.js +1 -0
- package/src/client/dist/spa/assets/css-gdwCt5by.js +3 -0
- package/src/client/dist/spa/assets/css.worker-D1piIYC4.js +102 -0
- package/src/client/dist/spa/assets/cssMode-DAo2zfyd.js +4 -0
- package/src/client/dist/spa/assets/cypher-ocmmfoQr.js +1 -0
- package/src/client/dist/spa/assets/dart-DbZ5eklb.js +1 -0
- package/src/client/dist/spa/assets/dockerfile-BLaMayDc.js +1 -0
- package/src/client/dist/spa/assets/ecl-LxXpHirr.js +1 -0
- package/src/client/dist/spa/assets/editor-COGk2gAX.css +1 -0
- package/src/client/dist/spa/assets/editor-CS3NEPi9.css +1 -0
- package/src/client/dist/spa/assets/editor.api-CWauCA5T.js +818 -0
- package/src/client/dist/spa/assets/editor.main-DN7hKNLf.js +53 -0
- package/src/client/dist/spa/assets/editor.worker-CJ9iTmkr.js +26 -0
- package/src/client/dist/spa/assets/elixir-C_geKt5o.js +1 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-OUIwM9U8.woff +0 -0
- package/src/client/dist/spa/assets/flow9-DE2fI2ca.js +1 -0
- package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +1 -0
- package/src/client/dist/spa/assets/freemarker2-DIOMlaEN.js +3 -0
- package/src/client/dist/spa/assets/fsharp-CJD6fImD.js +1 -0
- package/src/client/dist/spa/assets/go-jUCqQ7bD.js +1 -0
- package/src/client/dist/spa/assets/graphql-rw7g9h7D.js +1 -0
- package/src/client/dist/spa/assets/handlebars-COu39EGN.js +1 -0
- package/src/client/dist/spa/assets/hcl-BKX27Mn7.js +1 -0
- package/src/client/dist/spa/assets/html-XYszhvTl.js +1 -0
- package/src/client/dist/spa/assets/html.worker-C4q4XMPn.js +509 -0
- package/src/client/dist/spa/assets/htmlMode-lzWtybwj.js +4 -0
- package/src/client/dist/spa/assets/i18n-CqtBRZZ2.js +6 -0
- package/src/client/dist/spa/assets/index-V8B-qpFo.js +5 -0
- package/src/client/dist/spa/assets/{index-BThMCiY7.css → index-eX_lKHSg.css} +1 -1
- package/src/client/dist/spa/assets/ini-CrXjga2H.js +1 -0
- package/src/client/dist/spa/assets/java-D4jksGBb.js +1 -0
- package/src/client/dist/spa/assets/javascript-D5pAXrIT.js +1 -0
- package/src/client/dist/spa/assets/json.worker-C9p7xCYk.js +65 -0
- package/src/client/dist/spa/assets/jsonMode-CWSTNrKH.js +10 -0
- package/src/client/dist/spa/assets/julia-CbWxfkeS.js +1 -0
- package/src/client/dist/spa/assets/kotlin-B26Yx80V.js +1 -0
- package/src/client/dist/spa/assets/less-DFzn-zC9.js +2 -0
- package/src/client/dist/spa/assets/lexon-C-w-W8Yv.js +1 -0
- package/src/client/dist/spa/assets/liquid-BhTTi2Pq.js +1 -0
- package/src/client/dist/spa/assets/lua-CHuE_HoG.js +1 -0
- package/src/client/dist/spa/assets/m3-DEFZN2qS.js +1 -0
- package/src/client/dist/spa/assets/markdown-Cbt4TlFt.js +1 -0
- package/src/client/dist/spa/assets/mdx-Dy0u9FEz.js +1 -0
- package/src/client/dist/spa/assets/mips-C6m4XECw.js +1 -0
- package/src/client/dist/spa/assets/monaco.contribution-CZgTyM2p.js +2 -0
- package/src/client/dist/spa/assets/msdax-un0CFb_S.js +1 -0
- package/src/client/dist/spa/assets/mysql-CuAPeiOV.js +1 -0
- package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +1 -0
- package/src/client/dist/spa/assets/objective-c-DLVMdxAC.js +1 -0
- package/src/client/dist/spa/assets/pascal-BGCThuPY.js +1 -0
- package/src/client/dist/spa/assets/pascaligo-DfxSVpdo.js +1 -0
- package/src/client/dist/spa/assets/perl-BOE6y94t.js +1 -0
- package/src/client/dist/spa/assets/pgsql-Dn7JkY4F.js +1 -0
- package/src/client/dist/spa/assets/php-r1gD0KyT.js +1 -0
- package/src/client/dist/spa/assets/pla-CgXknhb0.js +1 -0
- package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +1 -0
- package/src/client/dist/spa/assets/postiats-CsIEtnRB.js +1 -0
- package/src/client/dist/spa/assets/powerquery-yNJCmC_6.js +1 -0
- package/src/client/dist/spa/assets/powershell-CQcz1SqH.js +1 -0
- package/src/client/dist/spa/assets/protobuf-BmC34uvO.js +2 -0
- package/src/client/dist/spa/assets/pug-C20znvWM.js +1 -0
- package/src/client/dist/spa/assets/python-XBs9JqmL.js +1 -0
- package/src/client/dist/spa/assets/qsharp-B7bnARMS.js +1 -0
- package/src/client/dist/spa/assets/r-ClvcLdqC.js +1 -0
- package/src/client/dist/spa/assets/razor-NhOuOwAX.js +1 -0
- package/src/client/dist/spa/assets/redis-DCyda7_S.js +1 -0
- package/src/client/dist/spa/assets/redshift-BtWDr4pb.js +1 -0
- package/src/client/dist/spa/assets/restructuredtext-CLcnlkhl.js +1 -0
- package/src/client/dist/spa/assets/ruby-DY0SOSSZ.js +1 -0
- package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +1 -0
- package/src/client/dist/spa/assets/rust-JQd-fJZI.js +1 -0
- package/src/client/dist/spa/assets/sb-BV2j8yFF.js +1 -0
- package/src/client/dist/spa/assets/scala-DwbnREDs.js +1 -0
- package/src/client/dist/spa/assets/scheme-CrtA-vei.js +1 -0
- package/src/client/dist/spa/assets/scss-VxQz3zmI.js +3 -0
- package/src/client/dist/spa/assets/shell-CP9faqFI.js +1 -0
- package/src/client/dist/spa/assets/solidity-9IIb0b89.js +1 -0
- package/src/client/dist/spa/assets/sophia-D2LQU2AD.js +1 -0
- package/src/client/dist/spa/assets/sparql-DONCa5dy.js +1 -0
- package/src/client/dist/spa/assets/sql-DaAAHGEt.js +1 -0
- package/src/client/dist/spa/assets/st-CRY2V-j3.js +1 -0
- package/src/client/dist/spa/assets/swift-BlKbfloF.js +1 -0
- package/src/client/dist/spa/assets/systemverilog-B_h9Q_T_.js +1 -0
- package/src/client/dist/spa/assets/tcl-C4wN3A6M.js +1 -0
- package/src/client/dist/spa/assets/ts.worker-Cj3zTgVE.js +51353 -0
- package/src/client/dist/spa/assets/tsMode-DjugmGYs.js +11 -0
- package/src/client/dist/spa/assets/twig-DDdaBLC9.js +1 -0
- package/src/client/dist/spa/assets/typescript-Bn1PVAbe.js +1 -0
- package/src/client/dist/spa/assets/typespec-Dc1ipt8A.js +1 -0
- package/src/client/dist/spa/assets/use-checkbox-DQlAvWsu.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-BU8KhIBO.js +1 -0
- package/src/client/dist/spa/assets/vb-C4BXIvrh.js +1 -0
- package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +3 -0
- package/src/client/dist/spa/assets/wgsl-XVg3Pi-r.js +298 -0
- package/src/client/dist/spa/assets/xml-92V2DCwu.js +1 -0
- package/src/client/dist/spa/assets/yaml-STyv5GVR.js +1 -0
- package/src/client/dist/spa/index.html +5 -3
- package/src/mcp-server/kobo-tasks-server.ts +2 -0
- package/src/client/dist/spa/assets/ActivityFeed-Bie-lcn7.js +0 -60
- package/src/client/dist/spa/assets/ActivityFeed-D88GOO2z.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-OC-fnNGP.js +0 -2
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
- package/src/client/dist/spa/assets/MainLayout-91cUoVYa.css +0 -1
- package/src/client/dist/spa/assets/MainLayout-BIQNJixM.js +0 -1
- package/src/client/dist/spa/assets/QBadge-DbE3eSf1.js +0 -1
- package/src/client/dist/spa/assets/QDialog-Cd_4PvgW.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-pMQDDRMv.js +0 -1
- package/src/client/dist/spa/assets/QPage-lhV4XbI2.js +0 -1
- package/src/client/dist/spa/assets/QSpinnerDots-ByNZaBWw.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-6GSFtFKP.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BPH70mno.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-s2WJBreM.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-Dhkuuhf8.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-XT26aCJE.js +0 -2
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-B6FaNy4R.js +0 -1
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
- package/src/client/dist/spa/assets/index-BoQWbZtE.js +0 -5
- package/src/client/dist/spa/assets/nodes-CXdiSdC2.js +0 -1
- package/src/client/dist/spa/assets/use-checkbox-Z9pfihkw.js +0 -1
- package/src/client/dist/spa/assets/use-quasar-CtCe3LQU.js +0 -1
package/AGENTS.md
CHANGED
|
@@ -171,6 +171,19 @@ See the "Notion integration" section of the README for the end-user setup guide.
|
|
|
171
171
|
|
|
172
172
|
**Dependencies** — root `package.json` covers backend + tests. `src/client/package.json` is a separate npm tree. Install both.
|
|
173
173
|
|
|
174
|
+
## Internationalization (i18n)
|
|
175
|
+
|
|
176
|
+
The frontend uses `vue-i18n` v10 with 5 supported locales: English (`en`), French (`fr`), German (`de`), Spanish (`es`), Italian (`it`). Translation files live in `src/client/src/i18n/`.
|
|
177
|
+
|
|
178
|
+
**Mandatory rules for all frontend code:**
|
|
179
|
+
|
|
180
|
+
- **NEVER hardcode user-visible text** in Vue templates or scripts. Always use `$t('key')` in templates and `t('key')` in `<script setup>` (via `const { t } = useI18n()`).
|
|
181
|
+
- When adding or modifying a text, **update ALL 5 locale files** (`en.ts`, `fr.ts`, `de.ts`, `es.ts`, `it.ts`) with the corresponding translation.
|
|
182
|
+
- Keys follow the pattern `'component.label'` (e.g. `'git.push'`, `'settings.title'`, `'common.save'`). Use the existing key structure as reference.
|
|
183
|
+
- Keep technical terms in English across all locales when that's the industry convention (Git, PR, Push, Diff, Commit, Branch, Tokens, etc.).
|
|
184
|
+
- Placeholders like `{count}`, `{n}`, `{query}` must remain intact in all translations.
|
|
185
|
+
- The language selector is in the Settings page (Global tab). The locale is auto-detected from the browser on first visit and persisted in `localStorage('kobo:locale')`.
|
|
186
|
+
|
|
174
187
|
## Testing discipline
|
|
175
188
|
|
|
176
189
|
- **TDD for backend** — write the failing test, confirm it fails for the right reason, implement minimally, confirm it passes, commit. One commit per logical unit. See existing tests in `src/__tests__/workspace-service.test.ts` for the setup pattern (fresh in-memory DB per test via `resetDb()`).
|
|
@@ -225,3 +238,4 @@ The human user of this repository prefers French for conversational exchanges. C
|
|
|
225
238
|
- Don't introduce ORMs, query builders, or schema validation libraries — the project is small enough for raw prepared statements and hand-written mappers.
|
|
226
239
|
- Don't break the single-source-of-truth of `CLAUDE.md` → `AGENTS.md` symlink. Edit `AGENTS.md`; `CLAUDE.md` follows automatically.
|
|
227
240
|
- Don't skip `try/catch` swallowing on best-effort cleanup (agent stop, dev-server stop, worktree removal). These must never break the primary operation.
|
|
241
|
+
- Don't hardcode user-visible text in the frontend. Every string must go through `$t()` / `t()` with keys in all 5 locale files. See [Internationalization (i18n)](#internationalization-i18n).
|
|
@@ -20,6 +20,7 @@ let db;
|
|
|
20
20
|
try {
|
|
21
21
|
db = new Database(dbPath, { readonly: false });
|
|
22
22
|
db.pragma('journal_mode = WAL');
|
|
23
|
+
db.pragma('busy_timeout = 5000');
|
|
23
24
|
db.pragma('foreign_keys = ON');
|
|
24
25
|
}
|
|
25
26
|
catch (err) {
|
|
@@ -205,6 +206,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
205
206
|
properties: {
|
|
206
207
|
status: {
|
|
207
208
|
type: 'string',
|
|
209
|
+
enum: ['idle', 'completed', 'error'],
|
|
208
210
|
description: 'New status (e.g. idle, completed)',
|
|
209
211
|
},
|
|
210
212
|
},
|
package/dist/server/db/index.js
CHANGED
|
@@ -7,6 +7,17 @@ export const migrations = [
|
|
|
7
7
|
db.exec("ALTER TABLE workspaces ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'auto-accept'");
|
|
8
8
|
},
|
|
9
9
|
},
|
|
10
|
+
{
|
|
11
|
+
version: 3,
|
|
12
|
+
name: 'add-workspace-id-indexes',
|
|
13
|
+
migrate: (db) => {
|
|
14
|
+
db.exec(`
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_id ON agent_sessions(workspace_id);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_id ON ws_events(workspace_id);
|
|
18
|
+
`);
|
|
19
|
+
},
|
|
20
|
+
},
|
|
10
21
|
];
|
|
11
22
|
/** Current schema version — always equals the highest migration version. */
|
|
12
23
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -46,5 +46,9 @@ export function initSchema(db) {
|
|
|
46
46
|
session_id TEXT,
|
|
47
47
|
created_at TEXT NOT NULL
|
|
48
48
|
);
|
|
49
|
+
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_id ON agent_sessions(workspace_id);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_id ON ws_events(workspace_id);
|
|
49
53
|
`);
|
|
50
54
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { serve } from '@hono/node-server';
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
7
|
import { WebSocketServer } from 'ws';
|
|
8
|
-
import { getDb } from './db/index.js';
|
|
8
|
+
import { closeDb, getDb } from './db/index.js';
|
|
9
9
|
import { runMigrations } from './db/migrations.js';
|
|
10
10
|
import devServerRouter from './routes/dev-server.js';
|
|
11
11
|
import gitRouter from './routes/git.js';
|
|
@@ -13,12 +13,13 @@ import imagesRouter from './routes/images.js';
|
|
|
13
13
|
import notionRouter from './routes/notion.js';
|
|
14
14
|
import settingsRouter from './routes/settings.js';
|
|
15
15
|
import workspacesRouter from './routes/workspaces.js';
|
|
16
|
-
import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, } from './services/agent-manager.js';
|
|
16
|
+
import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent-manager.js';
|
|
17
17
|
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
18
|
+
import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
|
|
18
19
|
import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
|
|
19
20
|
import { getLatestSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
|
|
20
|
-
import { getClientSpaPath, getKoboHome } from './utils/paths.js';
|
|
21
|
-
import { initProcessCleanup } from './utils/process-tracker.js';
|
|
21
|
+
import { getClientSpaPath, getKoboHome, getPackageVersion } from './utils/paths.js';
|
|
22
|
+
import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
|
|
22
23
|
// 0. Runtime prerequisite check — warn if claude CLI is missing. Don't block
|
|
23
24
|
// startup: the user may still want to configure settings or browse workspaces
|
|
24
25
|
// before installing Claude Code.
|
|
@@ -35,12 +36,11 @@ runMigrations(db);
|
|
|
35
36
|
// 2. Initialize process cleanup, agent watchdog, and PR watcher
|
|
36
37
|
initProcessCleanup();
|
|
37
38
|
startWatchdog();
|
|
38
|
-
import { startPrWatcher } from './services/pr-watcher-service.js';
|
|
39
39
|
startPrWatcher();
|
|
40
40
|
// 3. Create Hono app
|
|
41
41
|
const app = new Hono();
|
|
42
42
|
// Health check (root / is handled by the SPA catch-all below)
|
|
43
|
-
app.get('/api/health', (c) => c.json({ status: 'ok', version:
|
|
43
|
+
app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion() }));
|
|
44
44
|
// 4. Mount route sub-routers
|
|
45
45
|
app.route('/api/workspaces', workspacesRouter);
|
|
46
46
|
app.route('/api/workspaces', imagesRouter);
|
|
@@ -50,7 +50,7 @@ app.route('/api/settings', settingsRouter);
|
|
|
50
50
|
app.route('/api/dev-server', devServerRouter);
|
|
51
51
|
// Skills endpoint
|
|
52
52
|
app.get('/api/skills', (c) => c.json(getAvailableSkills()));
|
|
53
|
-
const PORT = process.env.
|
|
53
|
+
const PORT = parseInt(process.env.SERVER_PORT || process.env.PORT || '3000', 10);
|
|
54
54
|
// 9. Serve static files from the built SPA if present (production mode).
|
|
55
55
|
// The path is resolved relative to the package install directory, so this
|
|
56
56
|
// works both in dev (tsx running from src/) and when installed via npm / npx
|
|
@@ -182,3 +182,54 @@ server.on('upgrade', (request, socket, head) => {
|
|
|
182
182
|
socket.destroy();
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
|
+
// 9. Graceful shutdown handler
|
|
186
|
+
let isShuttingDown = false;
|
|
187
|
+
function gracefulShutdown(signal) {
|
|
188
|
+
if (isShuttingDown)
|
|
189
|
+
return;
|
|
190
|
+
isShuttingDown = true;
|
|
191
|
+
console.log(`\n[kobo] Received ${signal}, shutting down gracefully…`);
|
|
192
|
+
// 1. Stop accepting new connections
|
|
193
|
+
wss.close(() => {
|
|
194
|
+
console.log('[kobo] WebSocket server closed');
|
|
195
|
+
});
|
|
196
|
+
server.close(() => {
|
|
197
|
+
console.log('[kobo] HTTP server closed');
|
|
198
|
+
});
|
|
199
|
+
// 2. Stop background services
|
|
200
|
+
try {
|
|
201
|
+
stopWatchdog();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Best-effort
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
stopPrWatcher();
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Best-effort
|
|
211
|
+
}
|
|
212
|
+
// 3. Kill all tracked child processes (agents, dev servers)
|
|
213
|
+
try {
|
|
214
|
+
killAllTrackedProcesses();
|
|
215
|
+
console.log('[kobo] Tracked processes killed');
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Best-effort
|
|
219
|
+
}
|
|
220
|
+
// 4. Close database
|
|
221
|
+
try {
|
|
222
|
+
closeDb();
|
|
223
|
+
console.log('[kobo] Database closed');
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Best-effort
|
|
227
|
+
}
|
|
228
|
+
// 4. Give a short grace period for in-flight requests, then exit
|
|
229
|
+
setTimeout(() => {
|
|
230
|
+
console.log('[kobo] Shutdown complete');
|
|
231
|
+
process.exit(0);
|
|
232
|
+
}, 2000).unref();
|
|
233
|
+
}
|
|
234
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
235
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFileCb);
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
2
6
|
import { Hono } from 'hono';
|
|
7
|
+
import { getDb } from '../db/index.js';
|
|
3
8
|
import * as agentManager from '../services/agent-manager.js';
|
|
4
9
|
import * as devServerService from '../services/dev-server-service.js';
|
|
5
10
|
import * as notionService from '../services/notion-service.js';
|
|
11
|
+
import { renderPrTemplate } from '../services/pr-template-service.js';
|
|
6
12
|
import * as settingsService from '../services/settings-service.js';
|
|
13
|
+
import { runSetupScript } from '../services/setup-script-service.js';
|
|
7
14
|
import * as wsService from '../services/websocket-service.js';
|
|
8
15
|
import * as workspaceService from '../services/workspace-service.js';
|
|
9
16
|
import * as worktreeService from '../services/worktree-service.js';
|
|
@@ -110,9 +117,7 @@ app.post('/', async (c) => {
|
|
|
110
117
|
// 4b. Ensure Kobo-generated files inside .ai/ are gitignored (the .ai/ dir
|
|
111
118
|
// itself may contain project files that SHOULD be committed).
|
|
112
119
|
try {
|
|
113
|
-
const
|
|
114
|
-
const pathMod = await import('node:path');
|
|
115
|
-
const gitignorePath = pathMod.default.join(worktreePath, '.gitignore');
|
|
120
|
+
const gitignorePath = path.join(worktreePath, '.gitignore');
|
|
116
121
|
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
|
|
117
122
|
const lines = existing.split('\n').map((l) => l.trim());
|
|
118
123
|
const toAdd = [];
|
|
@@ -122,6 +127,8 @@ app.post('/', async (c) => {
|
|
|
122
127
|
toAdd.push('.ai/thoughts/');
|
|
123
128
|
if (!lines.includes('.ai/images/'))
|
|
124
129
|
toAdd.push('.ai/images/');
|
|
130
|
+
if (!lines.includes('.ai/.setup-script.tmp'))
|
|
131
|
+
toAdd.push('.ai/.setup-script.tmp');
|
|
125
132
|
if (toAdd.length > 0) {
|
|
126
133
|
const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
127
134
|
fs.appendFileSync(gitignorePath, `${separator}${toAdd.join('\n')}\n`, 'utf-8');
|
|
@@ -134,31 +141,50 @@ app.post('/', async (c) => {
|
|
|
134
141
|
const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
|
|
135
142
|
if (effectiveSettings.gitConventions) {
|
|
136
143
|
try {
|
|
137
|
-
const
|
|
138
|
-
const path = await import('node:path');
|
|
139
|
-
const aiDir = path.default.join(worktreePath, '.ai');
|
|
144
|
+
const aiDir = path.join(worktreePath, '.ai');
|
|
140
145
|
fs.mkdirSync(aiDir, { recursive: true });
|
|
141
|
-
const conventionsPath = path.
|
|
146
|
+
const conventionsPath = path.join(aiDir, 'git-conventions.md');
|
|
142
147
|
fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
|
|
143
148
|
}
|
|
144
149
|
catch (err) {
|
|
145
150
|
console.error('[workspaces] Failed to write git-conventions.md:', err);
|
|
146
151
|
}
|
|
147
152
|
}
|
|
153
|
+
// 4d. Run setup script if configured
|
|
154
|
+
if (effectiveSettings.setupScript) {
|
|
155
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
156
|
+
wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
|
|
157
|
+
try {
|
|
158
|
+
const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
|
|
159
|
+
workspaceName: workspace.name,
|
|
160
|
+
branchName: body.workingBranch,
|
|
161
|
+
sourceBranch: body.sourceBranch,
|
|
162
|
+
projectPath: body.projectPath,
|
|
163
|
+
});
|
|
164
|
+
if (result.exitCode !== 0) {
|
|
165
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
166
|
+
return c.json({ error: `Setup script failed with exit code ${result.exitCode}` }, 500);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
171
|
+
console.error(`[workspaces] Setup script error: ${message}`);
|
|
172
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
173
|
+
return c.json({ error: `Setup script error: ${message}` }, 500);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
148
176
|
// 5. Save Notion content as markdown in worktree
|
|
149
177
|
let notionFilePath = null;
|
|
150
178
|
if (notionContent && body.notionUrl) {
|
|
151
179
|
try {
|
|
152
|
-
const
|
|
153
|
-
const path = await import('node:path');
|
|
154
|
-
const thoughtsDir = path.default.join(worktreePath, '.ai', 'thoughts');
|
|
180
|
+
const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
|
|
155
181
|
fs.mkdirSync(thoughtsDir, { recursive: true });
|
|
156
182
|
// Derive filename from title (TK-XXX pattern or slug)
|
|
157
183
|
const tkMatch = workspace.name.match(/TK-\d+/i);
|
|
158
184
|
const filename = tkMatch
|
|
159
185
|
? `${tkMatch[0]}.md`
|
|
160
186
|
: `PAGE-${notionService.parseNotionUrl(body.notionUrl).replace(/-/g, '')}.md`;
|
|
161
|
-
notionFilePath = path.
|
|
187
|
+
notionFilePath = path.join(thoughtsDir, filename);
|
|
162
188
|
const today = new Date().toISOString().split('T')[0];
|
|
163
189
|
let md = `# ${workspace.name}\n\n`;
|
|
164
190
|
md += `## Source\n\n`;
|
|
@@ -219,8 +245,7 @@ app.post('/', async (c) => {
|
|
|
219
245
|
brainstormPrompt += `\nIMPORTANT: Start by reading CLAUDE.md and/or AGENTS.md at the project root if they exist — they contain project conventions and instructions you must follow.`;
|
|
220
246
|
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you're done brainstorming and have a clear plan, create a plan file and proceed with implementation. Once you have completed the brainstorming phase, output [BRAINSTORM_COMPLETE] on its own line.`;
|
|
221
247
|
// Persist the initial prompt in the feed so it's visible in the chat
|
|
222
|
-
|
|
223
|
-
emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
|
|
248
|
+
wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
|
|
224
249
|
try {
|
|
225
250
|
agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model);
|
|
226
251
|
}
|
|
@@ -269,7 +294,7 @@ app.post('/:id/refresh-notion', async (c) => {
|
|
|
269
294
|
return c.json({ error: 'No Notion URL configured' }, 400);
|
|
270
295
|
const notionContent = await notionService.extractNotionPage(workspace.notionUrl);
|
|
271
296
|
// Delete existing tasks and recreate from Notion
|
|
272
|
-
const db =
|
|
297
|
+
const db = getDb();
|
|
273
298
|
db.prepare('DELETE FROM tasks WHERE workspace_id = ?').run(id);
|
|
274
299
|
let sortOrder = 0;
|
|
275
300
|
for (const todo of notionContent.todos) {
|
|
@@ -327,7 +352,16 @@ app.post('/:id/tasks', async (c) => {
|
|
|
327
352
|
// PATCH /api/workspaces/:id/tasks/:taskId — update task status and/or title
|
|
328
353
|
app.patch('/:id/tasks/:taskId', async (c) => {
|
|
329
354
|
try {
|
|
355
|
+
const id = c.req.param('id');
|
|
330
356
|
const taskId = c.req.param('taskId');
|
|
357
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
358
|
+
if (!workspace) {
|
|
359
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
360
|
+
}
|
|
361
|
+
const task = workspaceService.getTask(taskId, id);
|
|
362
|
+
if (!task) {
|
|
363
|
+
return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
|
|
364
|
+
}
|
|
331
365
|
const body = await c.req.json();
|
|
332
366
|
if (body.status === undefined && body.title === undefined) {
|
|
333
367
|
return c.json({ error: 'At least one of status or title is required' }, 400);
|
|
@@ -361,6 +395,10 @@ app.delete('/:id/tasks/:taskId', (c) => {
|
|
|
361
395
|
if (!workspace) {
|
|
362
396
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
363
397
|
}
|
|
398
|
+
const task = workspaceService.getTask(taskId, id);
|
|
399
|
+
if (!task) {
|
|
400
|
+
return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
|
|
401
|
+
}
|
|
364
402
|
workspaceService.deleteTask(taskId);
|
|
365
403
|
return new Response(null, { status: 204 });
|
|
366
404
|
}
|
|
@@ -369,6 +407,24 @@ app.delete('/:id/tasks/:taskId', (c) => {
|
|
|
369
407
|
return c.json({ error: message }, 500);
|
|
370
408
|
}
|
|
371
409
|
});
|
|
410
|
+
// POST /api/workspaces/:id/tasks/notify-updated — broadcast generic task list change
|
|
411
|
+
// Must be declared BEFORE /:id/tasks/:taskId/notify-done so Hono doesn't capture
|
|
412
|
+
// "notify-updated" as a :taskId parameter.
|
|
413
|
+
app.post('/:id/tasks/notify-updated', (c) => {
|
|
414
|
+
try {
|
|
415
|
+
const id = c.req.param('id');
|
|
416
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
417
|
+
if (!workspace) {
|
|
418
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
419
|
+
}
|
|
420
|
+
wsService.emit(id, 'task:updated', {});
|
|
421
|
+
return new Response(null, { status: 204 });
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
425
|
+
return c.json({ error: message }, 500);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
372
428
|
// POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
|
|
373
429
|
app.post('/:id/tasks/:taskId/notify-done', (c) => {
|
|
374
430
|
try {
|
|
@@ -386,16 +442,66 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
|
|
|
386
442
|
return c.json({ error: message }, 500);
|
|
387
443
|
}
|
|
388
444
|
});
|
|
389
|
-
//
|
|
390
|
-
app.
|
|
445
|
+
// GET /api/workspaces/:id/events — paginated event history (must be before GET /:id for route ordering)
|
|
446
|
+
app.get('/:id/events', (c) => {
|
|
391
447
|
try {
|
|
392
448
|
const id = c.req.param('id');
|
|
393
449
|
const workspace = workspaceService.getWorkspace(id);
|
|
394
450
|
if (!workspace) {
|
|
395
451
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
396
452
|
}
|
|
397
|
-
|
|
398
|
-
|
|
453
|
+
const before = c.req.query('before'); // event ID cursor
|
|
454
|
+
const limit = Math.min(parseInt(c.req.query('limit') ?? '100', 10) || 100, 500);
|
|
455
|
+
const db = getDb();
|
|
456
|
+
let rows;
|
|
457
|
+
if (before) {
|
|
458
|
+
// Get the rowid of the cursor event
|
|
459
|
+
const cursorRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(before);
|
|
460
|
+
if (!cursorRow) {
|
|
461
|
+
return c.json({ events: [], hasMore: false });
|
|
462
|
+
}
|
|
463
|
+
rows = db
|
|
464
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?')
|
|
465
|
+
.all(id, cursorRow.rowid, limit);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
// No cursor — return the oldest events
|
|
469
|
+
rows = db
|
|
470
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
|
|
471
|
+
.all(id, limit);
|
|
472
|
+
}
|
|
473
|
+
// Reverse to chronological order (we queried DESC for "before" pagination)
|
|
474
|
+
if (before)
|
|
475
|
+
rows.reverse();
|
|
476
|
+
const events = rows.map((row) => {
|
|
477
|
+
let parsedPayload;
|
|
478
|
+
try {
|
|
479
|
+
parsedPayload = JSON.parse(row.payload);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
parsedPayload = row.payload;
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
id: row.id,
|
|
486
|
+
workspaceId: row.workspace_id,
|
|
487
|
+
type: row.type,
|
|
488
|
+
payload: parsedPayload,
|
|
489
|
+
sessionId: row.session_id,
|
|
490
|
+
createdAt: row.created_at,
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
// Check if there are more older events beyond what we returned
|
|
494
|
+
let hasMore = false;
|
|
495
|
+
if (before && rows.length > 0) {
|
|
496
|
+
const firstRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(rows[0].id);
|
|
497
|
+
if (firstRow) {
|
|
498
|
+
const older = db
|
|
499
|
+
.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND rowid < ?')
|
|
500
|
+
.get(id, firstRow.rowid);
|
|
501
|
+
hasMore = older.c > 0;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return c.json({ events, hasMore });
|
|
399
505
|
}
|
|
400
506
|
catch (err) {
|
|
401
507
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -612,12 +718,12 @@ app.get('/:id/git-stats', async (c) => {
|
|
|
612
718
|
if (!workspace) {
|
|
613
719
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
614
720
|
}
|
|
615
|
-
const
|
|
616
|
-
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
721
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
617
722
|
const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
618
723
|
const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
619
|
-
const pr = gitOps.
|
|
620
|
-
const unpushedCount = gitOps.
|
|
724
|
+
const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
|
|
725
|
+
const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath);
|
|
726
|
+
const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
|
|
621
727
|
return c.json({
|
|
622
728
|
commitCount,
|
|
623
729
|
filesChanged: diffStats.filesChanged,
|
|
@@ -626,6 +732,28 @@ app.get('/:id/git-stats', async (c) => {
|
|
|
626
732
|
prUrl: pr?.url ?? null,
|
|
627
733
|
prState: pr?.state ?? null,
|
|
628
734
|
unpushedCount,
|
|
735
|
+
workingTree,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
740
|
+
return c.json({ error: message }, 500);
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
// GET /api/workspaces/:id/diff — list changed files
|
|
744
|
+
app.get('/:id/diff', (c) => {
|
|
745
|
+
try {
|
|
746
|
+
const id = c.req.param('id');
|
|
747
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
748
|
+
if (!workspace) {
|
|
749
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
750
|
+
}
|
|
751
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
752
|
+
const files = gitOps.getChangedFiles(worktreePath, workspace.sourceBranch);
|
|
753
|
+
return c.json({
|
|
754
|
+
files,
|
|
755
|
+
sourceBranch: workspace.sourceBranch,
|
|
756
|
+
workingBranch: workspace.workingBranch,
|
|
629
757
|
});
|
|
630
758
|
}
|
|
631
759
|
catch (err) {
|
|
@@ -633,6 +761,28 @@ app.get('/:id/git-stats', async (c) => {
|
|
|
633
761
|
return c.json({ error: message }, 500);
|
|
634
762
|
}
|
|
635
763
|
});
|
|
764
|
+
// GET /api/workspaces/:id/diff/:filePath — get original and modified content for a file
|
|
765
|
+
app.get('/:id/diff-file', (c) => {
|
|
766
|
+
try {
|
|
767
|
+
const id = c.req.param('id');
|
|
768
|
+
const filePath = c.req.query('path');
|
|
769
|
+
if (!filePath) {
|
|
770
|
+
return c.json({ error: 'Missing path query parameter' }, 400);
|
|
771
|
+
}
|
|
772
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
773
|
+
if (!workspace) {
|
|
774
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
775
|
+
}
|
|
776
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
777
|
+
const original = gitOps.getFileAtRef(worktreePath, workspace.sourceBranch, filePath);
|
|
778
|
+
const modified = gitOps.getFileContent(worktreePath, filePath);
|
|
779
|
+
return c.json({ original: original ?? '', modified: modified ?? '', filePath });
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
783
|
+
return c.json({ error: message }, 500);
|
|
784
|
+
}
|
|
785
|
+
});
|
|
636
786
|
// POST /api/workspaces/:id/push — push working branch to origin
|
|
637
787
|
app.post('/:id/push', async (c) => {
|
|
638
788
|
try {
|
|
@@ -641,8 +791,7 @@ app.post('/:id/push', async (c) => {
|
|
|
641
791
|
if (!workspace) {
|
|
642
792
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
643
793
|
}
|
|
644
|
-
const
|
|
645
|
-
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
794
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
646
795
|
try {
|
|
647
796
|
gitOps.pushBranch(worktreePath, workspace.workingBranch);
|
|
648
797
|
}
|
|
@@ -651,10 +800,9 @@ app.post('/:id/push', async (c) => {
|
|
|
651
800
|
return c.json({ error: message }, 500);
|
|
652
801
|
}
|
|
653
802
|
// Emit a trace into the chat feed so the user sees the action
|
|
654
|
-
const { emit } = await import('../services/websocket-service.js');
|
|
655
803
|
const session = workspaceService.getLatestSession(id);
|
|
656
804
|
const sessionId = session?.claudeSessionId ?? undefined;
|
|
657
|
-
emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, sessionId);
|
|
805
|
+
wsService.emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, sessionId);
|
|
658
806
|
return c.json({ ok: true, branch: workspace.workingBranch });
|
|
659
807
|
}
|
|
660
808
|
catch (err) {
|
|
@@ -670,15 +818,14 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
670
818
|
if (!workspace) {
|
|
671
819
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
672
820
|
}
|
|
673
|
-
const
|
|
674
|
-
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
821
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
675
822
|
// 1. Check branch is on remote
|
|
676
823
|
let lsRemoteOut = '';
|
|
677
824
|
try {
|
|
678
|
-
const
|
|
825
|
+
const { stdout } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
|
|
679
826
|
cwd: worktreePath,
|
|
680
827
|
});
|
|
681
|
-
lsRemoteOut =
|
|
828
|
+
lsRemoteOut = stdout;
|
|
682
829
|
}
|
|
683
830
|
catch {
|
|
684
831
|
lsRemoteOut = '';
|
|
@@ -688,8 +835,8 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
688
835
|
}
|
|
689
836
|
// 2. Check all local commits are pushed
|
|
690
837
|
try {
|
|
691
|
-
const
|
|
692
|
-
const countStr =
|
|
838
|
+
const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
|
|
839
|
+
const countStr = stdout.trim();
|
|
693
840
|
const count = parseInt(countStr, 10) || 0;
|
|
694
841
|
if (count > 0) {
|
|
695
842
|
return c.json({ error: 'Local commits not pushed', code: 'unpushed_commits' }, 409);
|
|
@@ -708,7 +855,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
708
855
|
let ghOutput;
|
|
709
856
|
try {
|
|
710
857
|
const placeholderBody = 'Automated PR — description will be updated by the agent.';
|
|
711
|
-
const
|
|
858
|
+
const { stdout } = await execFileAsync('gh', [
|
|
712
859
|
'pr',
|
|
713
860
|
'create',
|
|
714
861
|
'--base',
|
|
@@ -720,7 +867,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
720
867
|
'--body',
|
|
721
868
|
placeholderBody,
|
|
722
869
|
], { cwd: worktreePath });
|
|
723
|
-
ghOutput =
|
|
870
|
+
ghOutput = stdout;
|
|
724
871
|
}
|
|
725
872
|
catch (err) {
|
|
726
873
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -741,7 +888,6 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
741
888
|
return c.json({ ok: true, prNumber, prUrl, messageSent: false });
|
|
742
889
|
}
|
|
743
890
|
// 6. Build context and render the template
|
|
744
|
-
const { renderPrTemplate } = await import('../services/pr-template-service.js');
|
|
745
891
|
const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
746
892
|
const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
747
893
|
const tasks = workspaceService.listTasks(workspace.id);
|
|
@@ -754,10 +900,9 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
754
900
|
tasks,
|
|
755
901
|
});
|
|
756
902
|
// 7. Emit user:message into the chat feed
|
|
757
|
-
const { emit } = await import('../services/websocket-service.js');
|
|
758
903
|
const session = workspaceService.getLatestSession(workspace.id);
|
|
759
904
|
const sessionId = session?.claudeSessionId ?? undefined;
|
|
760
|
-
emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
|
|
905
|
+
wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
|
|
761
906
|
// 8. Send to the running agent, or resume the agent with the PR prompt
|
|
762
907
|
let messageSent = false;
|
|
763
908
|
try {
|
|
@@ -308,7 +308,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
308
308
|
const text = data.toString();
|
|
309
309
|
const lowerText = text.toLowerCase();
|
|
310
310
|
if (lowerText.includes('rate limit') || lowerText.includes('quota') || lowerText.includes('limit exceeded')) {
|
|
311
|
-
handleQuota(workspaceId,
|
|
311
|
+
handleQuota(workspaceId, agent.claudeSessionId);
|
|
312
312
|
}
|
|
313
313
|
// Also emit stderr for visibility
|
|
314
314
|
emit(workspaceId, 'agent:stderr', { content: text }, agent.claudeSessionId);
|
|
@@ -325,7 +325,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
325
325
|
// I3: Close readline interface to release the stream reference
|
|
326
326
|
agent.rl.close();
|
|
327
327
|
unregisterProcess(workspaceId);
|
|
328
|
-
|
|
328
|
+
// Only remove from the map if this exact agent instance is still current.
|
|
329
|
+
// stopAgent() eagerly removes the entry so startAgent() can proceed
|
|
330
|
+
// immediately; if a new agent was started in the meantime, we must not
|
|
331
|
+
// remove it.
|
|
332
|
+
if (agents.get(workspaceId) === agent) {
|
|
333
|
+
agents.delete(workspaceId);
|
|
334
|
+
}
|
|
329
335
|
// Clean up retry state and inactivity timer
|
|
330
336
|
retryCounts.delete(workspaceId);
|
|
331
337
|
// C2: Clear the kill timer if it's still pending (process exited naturally before SIGKILL)
|
|
@@ -382,6 +388,10 @@ export function stopAgent(workspaceId) {
|
|
|
382
388
|
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
383
389
|
}
|
|
384
390
|
agent.status = 'stopping';
|
|
391
|
+
// Remove from the map immediately so startAgent() can be called right after
|
|
392
|
+
// without hitting "Agent already running". The exit handler checks identity
|
|
393
|
+
// before removing, so a new agent started in the meantime won't be affected.
|
|
394
|
+
agents.delete(workspaceId);
|
|
385
395
|
// Cancel any pending backoff timer
|
|
386
396
|
const timer = backoffTimers.get(workspaceId);
|
|
387
397
|
if (timer) {
|
|
@@ -404,8 +414,10 @@ export function stopAgent(workspaceId) {
|
|
|
404
414
|
}
|
|
405
415
|
// After 5s timeout, send SIGKILL if still running
|
|
406
416
|
const killTimer = setTimeout(() => {
|
|
407
|
-
// C2:
|
|
408
|
-
|
|
417
|
+
// C2: If a new agent has been started for this workspace in the meantime,
|
|
418
|
+
// don't kill the old process — it's handled by the new lifecycle.
|
|
419
|
+
const currentAgent = agents.get(workspaceId);
|
|
420
|
+
if (currentAgent && currentAgent !== agent) {
|
|
409
421
|
killTimers.delete(workspaceId);
|
|
410
422
|
return;
|
|
411
423
|
}
|
|
@@ -446,7 +458,7 @@ export function getAvailableSkills() {
|
|
|
446
458
|
return availableSkills;
|
|
447
459
|
}
|
|
448
460
|
// ── Quota handling ─────────────────────────────────────────────────────────────
|
|
449
|
-
function handleQuota(workspaceId,
|
|
461
|
+
function handleQuota(workspaceId, claudeSessionId) {
|
|
450
462
|
// Update workspace status
|
|
451
463
|
try {
|
|
452
464
|
updateWorkspaceStatus(workspaceId, 'quota');
|
|
@@ -471,8 +483,14 @@ function handleQuota(workspaceId, workingDir, claudeSessionId) {
|
|
|
471
483
|
backoffTimers.delete(workspaceId);
|
|
472
484
|
// Only restart if not already running or stopped
|
|
473
485
|
if (!agents.has(workspaceId)) {
|
|
486
|
+
// Re-read workspace from DB — it may have been deleted or archived during backoff
|
|
487
|
+
const freshWs = getWs(workspaceId);
|
|
488
|
+
if (!freshWs || freshWs.archivedAt !== null || freshWs.status !== 'quota') {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
474
491
|
try {
|
|
475
|
-
|
|
492
|
+
const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
|
|
493
|
+
startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
|
|
476
494
|
}
|
|
477
495
|
catch {
|
|
478
496
|
// Agent restart failed
|