@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
package/AGENTS.md
CHANGED
|
@@ -10,7 +10,7 @@ Single-user dev tool, local by default. The server binds `127.0.0.1` unless the
|
|
|
10
10
|
|
|
11
11
|
## Tech stack
|
|
12
12
|
|
|
13
|
-
**Backend**: Node.js ≥
|
|
13
|
+
**Backend**: Node.js ≥ 24.15, Hono (HTTP), `ws` (WebSocket), better-sqlite3 (WAL mode), nanoid, `@modelcontextprotocol/sdk`. TypeScript throughout, `tsx` for dev, `tsc` for production build.
|
|
14
14
|
|
|
15
15
|
**Frontend**: Vue 3, Quasar 2, Pinia, vue-router, marked + dompurify for markdown rendering. Vite via `@quasar/app-vite`, built and served as a Progressive Web App in production.
|
|
16
16
|
|
|
@@ -26,6 +26,7 @@ Single-user dev tool, local by default. The server binds `127.0.0.1` unless the
|
|
|
26
26
|
# Install
|
|
27
27
|
npm install # root
|
|
28
28
|
(cd src/client && npm install) # client — separate tree
|
|
29
|
+
(cd src/client/src-pwa && npm install) # Quasar PWA build — separate tree
|
|
29
30
|
|
|
30
31
|
# Develop
|
|
31
32
|
npm run dev # backend only (tsx watch on src/server/index.ts)
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,11 @@ All notable changes to Kōbō are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
|
|
5
5
|
section — the in-app "What's new" dialog reads this file.
|
|
6
6
|
|
|
7
|
+
## 1.11.10
|
|
8
|
+
|
|
9
|
+
- docs: update setup guide
|
|
10
|
+
- fix: improve app reliability
|
|
11
|
+
|
|
7
12
|
## 1.11.9
|
|
8
13
|
|
|
9
14
|
- fix: address 19 bugs found by parallel codebase audit
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Kōbō runs multiple coding agents in parallel, each isolated in its own git wor
|
|
|
21
21
|
- **Full MCP toolset (`kobo-tasks`)**: a per-workspace MCP server the agent uses for far more than tasks — task/acceptance-criteria CRUD, starting/stopping the dev server and reading its logs, a unified `get_ticket` (Notion or Sentry), searching past conversations across every workspace, per-session token/cost usage, and a `.ai/thoughts` decision log. Native Claude Code Task tools complement it for lightweight sub-agent coordination. See [`AGENTS.md`](./AGENTS.md) for the full tool list.
|
|
22
22
|
|
|
23
23
|

|
|
24
|
-
- **Git panel**: a Monaco-based diff viewer with **inline file editing** (edit the right-hand panel directly, save with `Ctrl/Cmd+S`, conflict-guarded via sha precondition), inline conflict resolution
|
|
24
|
+
- **Git panel**: a Monaco-based diff viewer with **inline file editing** (edit the right-hand panel directly, save with `Ctrl/Cmd+S`, conflict-guarded via sha precondition) and syntax highlighting for PHP, Vue SFCs, JavaScript, TypeScript, and the existing web/config languages. It also provides inline conflict resolution and `Sync` / `Push` / `Commit` / `Open PR` / `Merge ready PR` / `Change PR base` / `Change source branch`. You can write the commit message yourself or ask the active engine to prepare the commit; PR/MR merging always requires a confirmation, then Kōbō can offer to delete the merged remote branch. Edit/Write cards in chat open their exact changed file in the diff viewer. Multi-forge: GitHub (`gh`), GitLab (`glab`), Bitbucket Community (`bkt`), or no forge, auto-detected from the remote and overridable per project.
|
|
25
25
|
|
|
26
26
|

|
|
27
27
|
- **Dev server panel**: start, stop, and tail logs for a workspace's dev server (Docker or npm) straight from the Tools panel — no need to leave the UI. When a project has no configured server, the panel and MCP tool report that explicitly instead of implying one is running.
|
|
@@ -41,7 +41,7 @@ Kōbō runs multiple coding agents in parallel, each isolated in its own git wor
|
|
|
41
41
|
|
|
42
42
|
## Quick start
|
|
43
43
|
|
|
44
|
-
Requires Node.js ≥
|
|
44
|
+
Requires Node.js ≥ 24.15 and a logged-in Claude Code **or** Codex CLI.
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
npx @loicngr/kobo@latest
|
|
@@ -461,6 +461,136 @@ export const migrations = [
|
|
|
461
461
|
`);
|
|
462
462
|
},
|
|
463
463
|
},
|
|
464
|
+
{
|
|
465
|
+
version: 34,
|
|
466
|
+
name: 'add-session-event-metrics',
|
|
467
|
+
migrate: (db) => {
|
|
468
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
|
|
469
|
+
if (!tables.has('workspaces') || !tables.has('agent_sessions') || !tables.has('ws_events'))
|
|
470
|
+
return;
|
|
471
|
+
db.exec(`
|
|
472
|
+
CREATE TABLE IF NOT EXISTS session_event_metrics (
|
|
473
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
474
|
+
session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
|
475
|
+
tool_calls INTEGER NOT NULL DEFAULT 0,
|
|
476
|
+
errors INTEGER NOT NULL DEFAULT 0,
|
|
477
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
478
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
479
|
+
PRIMARY KEY (workspace_id, session_id)
|
|
480
|
+
);
|
|
481
|
+
CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_type_session
|
|
482
|
+
ON ws_events(workspace_id, type, session_id, created_at);
|
|
483
|
+
CREATE INDEX IF NOT EXISTS idx_ws_events_type_created
|
|
484
|
+
ON ws_events(type, created_at DESC, id DESC);
|
|
485
|
+
|
|
486
|
+
CREATE TRIGGER IF NOT EXISTS trg_ws_events_metrics_insert
|
|
487
|
+
AFTER INSERT ON ws_events
|
|
488
|
+
WHEN NEW.type = 'agent:event'
|
|
489
|
+
AND NEW.session_id IS NOT NULL
|
|
490
|
+
AND json_valid(NEW.payload)
|
|
491
|
+
AND EXISTS (
|
|
492
|
+
SELECT 1 FROM agent_sessions
|
|
493
|
+
WHERE id = NEW.session_id AND workspace_id = NEW.workspace_id
|
|
494
|
+
)
|
|
495
|
+
BEGIN
|
|
496
|
+
INSERT INTO session_event_metrics (
|
|
497
|
+
workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
|
|
498
|
+
) VALUES (
|
|
499
|
+
NEW.workspace_id,
|
|
500
|
+
NEW.session_id,
|
|
501
|
+
CASE WHEN json_extract(NEW.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END,
|
|
502
|
+
CASE
|
|
503
|
+
WHEN json_extract(NEW.payload, '$.kind') = 'error'
|
|
504
|
+
OR (json_extract(NEW.payload, '$.kind') = 'tool:result'
|
|
505
|
+
AND json_extract(NEW.payload, '$.isError') = 1)
|
|
506
|
+
THEN 1 ELSE 0
|
|
507
|
+
END,
|
|
508
|
+
CASE
|
|
509
|
+
WHEN json_extract(NEW.payload, '$.kind') = 'usage'
|
|
510
|
+
AND json_type(NEW.payload, '$.inputTokens') IN ('integer', 'real')
|
|
511
|
+
THEN CAST(json_extract(NEW.payload, '$.inputTokens') AS INTEGER) ELSE 0
|
|
512
|
+
END,
|
|
513
|
+
CASE
|
|
514
|
+
WHEN json_extract(NEW.payload, '$.kind') = 'usage'
|
|
515
|
+
AND json_type(NEW.payload, '$.outputTokens') IN ('integer', 'real')
|
|
516
|
+
THEN CAST(json_extract(NEW.payload, '$.outputTokens') AS INTEGER) ELSE 0
|
|
517
|
+
END
|
|
518
|
+
)
|
|
519
|
+
ON CONFLICT(workspace_id, session_id) DO UPDATE SET
|
|
520
|
+
tool_calls = tool_calls + excluded.tool_calls,
|
|
521
|
+
errors = errors + excluded.errors,
|
|
522
|
+
input_tokens = MAX(input_tokens, excluded.input_tokens),
|
|
523
|
+
output_tokens = MAX(output_tokens, excluded.output_tokens);
|
|
524
|
+
END;
|
|
525
|
+
|
|
526
|
+
CREATE TRIGGER IF NOT EXISTS trg_ws_events_metrics_delete
|
|
527
|
+
AFTER DELETE ON ws_events
|
|
528
|
+
WHEN OLD.type = 'agent:event' AND OLD.session_id IS NOT NULL
|
|
529
|
+
BEGIN
|
|
530
|
+
DELETE FROM session_event_metrics
|
|
531
|
+
WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id;
|
|
532
|
+
|
|
533
|
+
INSERT INTO session_event_metrics (
|
|
534
|
+
workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
|
|
535
|
+
)
|
|
536
|
+
SELECT
|
|
537
|
+
e.workspace_id,
|
|
538
|
+
e.session_id,
|
|
539
|
+
SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
|
|
540
|
+
SUM(CASE
|
|
541
|
+
WHEN json_extract(e.payload, '$.kind') = 'error'
|
|
542
|
+
OR (json_extract(e.payload, '$.kind') = 'tool:result'
|
|
543
|
+
AND json_extract(e.payload, '$.isError') = 1)
|
|
544
|
+
THEN 1 ELSE 0
|
|
545
|
+
END),
|
|
546
|
+
MAX(CASE
|
|
547
|
+
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
548
|
+
AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
|
|
549
|
+
THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
|
|
550
|
+
END),
|
|
551
|
+
MAX(CASE
|
|
552
|
+
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
553
|
+
AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
|
|
554
|
+
THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
|
|
555
|
+
END)
|
|
556
|
+
FROM ws_events e
|
|
557
|
+
JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
|
|
558
|
+
WHERE e.workspace_id = OLD.workspace_id
|
|
559
|
+
AND e.session_id = OLD.session_id
|
|
560
|
+
AND e.type = 'agent:event'
|
|
561
|
+
AND json_valid(e.payload)
|
|
562
|
+
GROUP BY e.workspace_id, e.session_id;
|
|
563
|
+
END;
|
|
564
|
+
|
|
565
|
+
INSERT INTO session_event_metrics (
|
|
566
|
+
workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
|
|
567
|
+
)
|
|
568
|
+
SELECT
|
|
569
|
+
e.workspace_id,
|
|
570
|
+
e.session_id,
|
|
571
|
+
SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
|
|
572
|
+
SUM(CASE
|
|
573
|
+
WHEN json_extract(e.payload, '$.kind') = 'error'
|
|
574
|
+
OR (json_extract(e.payload, '$.kind') = 'tool:result'
|
|
575
|
+
AND json_extract(e.payload, '$.isError') = 1)
|
|
576
|
+
THEN 1 ELSE 0
|
|
577
|
+
END),
|
|
578
|
+
MAX(CASE WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
579
|
+
THEN COALESCE(CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER), 0) ELSE 0 END),
|
|
580
|
+
MAX(CASE WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
581
|
+
THEN COALESCE(CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER), 0) ELSE 0 END)
|
|
582
|
+
FROM ws_events e
|
|
583
|
+
JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
|
|
584
|
+
WHERE e.type = 'agent:event' AND e.session_id IS NOT NULL AND json_valid(e.payload)
|
|
585
|
+
GROUP BY e.workspace_id, e.session_id
|
|
586
|
+
ON CONFLICT(workspace_id, session_id) DO UPDATE SET
|
|
587
|
+
tool_calls = excluded.tool_calls,
|
|
588
|
+
errors = excluded.errors,
|
|
589
|
+
input_tokens = excluded.input_tokens,
|
|
590
|
+
output_tokens = excluded.output_tokens;
|
|
591
|
+
`);
|
|
592
|
+
},
|
|
593
|
+
},
|
|
464
594
|
];
|
|
465
595
|
/** Current schema version — always equals the highest migration version. */
|
|
466
596
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
|
@@ -479,37 +609,43 @@ export function runMigrations(db) {
|
|
|
479
609
|
if (hasLegacy) {
|
|
480
610
|
const legacyRow = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
|
|
481
611
|
const legacyVersion = legacyRow?.version ?? 0;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
612
|
+
db.transaction(() => {
|
|
613
|
+
// Back-fill history for all migrations that were already applied under the old system.
|
|
614
|
+
if (legacyVersion >= 1) {
|
|
615
|
+
const now = new Date().toISOString();
|
|
616
|
+
// Version 1 = initSchema (always applied if legacyVersion >= 1)
|
|
617
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
|
|
618
|
+
for (const m of migrations) {
|
|
619
|
+
if (m.version <= legacyVersion) {
|
|
620
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
|
|
621
|
+
}
|
|
490
622
|
}
|
|
491
623
|
}
|
|
492
|
-
|
|
493
|
-
|
|
624
|
+
db.exec('DROP TABLE schema_version');
|
|
625
|
+
})();
|
|
494
626
|
}
|
|
495
627
|
// ── Determine current state ────────────────────────────────────────────────
|
|
496
628
|
const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
|
|
497
629
|
// Fresh install — no migrations applied yet.
|
|
498
630
|
if (!applied.has(1)) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
631
|
+
db.transaction(() => {
|
|
632
|
+
initSchema(db);
|
|
633
|
+
const now = new Date().toISOString();
|
|
634
|
+
db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
|
|
635
|
+
// Mark all incremental migrations as applied (initSchema creates the latest shape).
|
|
636
|
+
for (const m of migrations) {
|
|
637
|
+
db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
|
|
638
|
+
}
|
|
639
|
+
})();
|
|
506
640
|
return;
|
|
507
641
|
}
|
|
508
642
|
// Apply pending migrations sequentially.
|
|
509
643
|
for (const m of migrations) {
|
|
510
644
|
if (!applied.has(m.version)) {
|
|
511
|
-
|
|
512
|
-
|
|
645
|
+
db.transaction(() => {
|
|
646
|
+
m.migrate(db);
|
|
647
|
+
db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, new Date().toISOString());
|
|
648
|
+
})();
|
|
513
649
|
}
|
|
514
650
|
}
|
|
515
651
|
// Soft downgrade guard: if the database carries a migration version this
|
package/dist/server/db/schema.js
CHANGED
|
@@ -74,6 +74,95 @@ export function initSchema(db) {
|
|
|
74
74
|
created_at TEXT NOT NULL
|
|
75
75
|
);
|
|
76
76
|
|
|
77
|
+
CREATE TABLE IF NOT EXISTS session_event_metrics (
|
|
78
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
79
|
+
session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
|
80
|
+
tool_calls INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
errors INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
83
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
84
|
+
PRIMARY KEY (workspace_id, session_id)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
CREATE TRIGGER IF NOT EXISTS trg_ws_events_metrics_insert
|
|
88
|
+
AFTER INSERT ON ws_events
|
|
89
|
+
WHEN NEW.type = 'agent:event'
|
|
90
|
+
AND NEW.session_id IS NOT NULL
|
|
91
|
+
AND json_valid(NEW.payload)
|
|
92
|
+
AND EXISTS (
|
|
93
|
+
SELECT 1 FROM agent_sessions
|
|
94
|
+
WHERE id = NEW.session_id AND workspace_id = NEW.workspace_id
|
|
95
|
+
)
|
|
96
|
+
BEGIN
|
|
97
|
+
INSERT INTO session_event_metrics (
|
|
98
|
+
workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
|
|
99
|
+
) VALUES (
|
|
100
|
+
NEW.workspace_id,
|
|
101
|
+
NEW.session_id,
|
|
102
|
+
CASE WHEN json_extract(NEW.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END,
|
|
103
|
+
CASE
|
|
104
|
+
WHEN json_extract(NEW.payload, '$.kind') = 'error'
|
|
105
|
+
OR (json_extract(NEW.payload, '$.kind') = 'tool:result'
|
|
106
|
+
AND json_extract(NEW.payload, '$.isError') = 1)
|
|
107
|
+
THEN 1 ELSE 0
|
|
108
|
+
END,
|
|
109
|
+
CASE
|
|
110
|
+
WHEN json_extract(NEW.payload, '$.kind') = 'usage'
|
|
111
|
+
AND json_type(NEW.payload, '$.inputTokens') IN ('integer', 'real')
|
|
112
|
+
THEN CAST(json_extract(NEW.payload, '$.inputTokens') AS INTEGER) ELSE 0
|
|
113
|
+
END,
|
|
114
|
+
CASE
|
|
115
|
+
WHEN json_extract(NEW.payload, '$.kind') = 'usage'
|
|
116
|
+
AND json_type(NEW.payload, '$.outputTokens') IN ('integer', 'real')
|
|
117
|
+
THEN CAST(json_extract(NEW.payload, '$.outputTokens') AS INTEGER) ELSE 0
|
|
118
|
+
END
|
|
119
|
+
)
|
|
120
|
+
ON CONFLICT(workspace_id, session_id) DO UPDATE SET
|
|
121
|
+
tool_calls = tool_calls + excluded.tool_calls,
|
|
122
|
+
errors = errors + excluded.errors,
|
|
123
|
+
input_tokens = MAX(input_tokens, excluded.input_tokens),
|
|
124
|
+
output_tokens = MAX(output_tokens, excluded.output_tokens);
|
|
125
|
+
END;
|
|
126
|
+
|
|
127
|
+
CREATE TRIGGER IF NOT EXISTS trg_ws_events_metrics_delete
|
|
128
|
+
AFTER DELETE ON ws_events
|
|
129
|
+
WHEN OLD.type = 'agent:event' AND OLD.session_id IS NOT NULL
|
|
130
|
+
BEGIN
|
|
131
|
+
DELETE FROM session_event_metrics
|
|
132
|
+
WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id;
|
|
133
|
+
|
|
134
|
+
INSERT INTO session_event_metrics (
|
|
135
|
+
workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
|
|
136
|
+
)
|
|
137
|
+
SELECT
|
|
138
|
+
e.workspace_id,
|
|
139
|
+
e.session_id,
|
|
140
|
+
SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
|
|
141
|
+
SUM(CASE
|
|
142
|
+
WHEN json_extract(e.payload, '$.kind') = 'error'
|
|
143
|
+
OR (json_extract(e.payload, '$.kind') = 'tool:result'
|
|
144
|
+
AND json_extract(e.payload, '$.isError') = 1)
|
|
145
|
+
THEN 1 ELSE 0
|
|
146
|
+
END),
|
|
147
|
+
MAX(CASE
|
|
148
|
+
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
149
|
+
AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
|
|
150
|
+
THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
|
|
151
|
+
END),
|
|
152
|
+
MAX(CASE
|
|
153
|
+
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
154
|
+
AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
|
|
155
|
+
THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
|
|
156
|
+
END)
|
|
157
|
+
FROM ws_events e
|
|
158
|
+
JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
|
|
159
|
+
WHERE e.workspace_id = OLD.workspace_id
|
|
160
|
+
AND e.session_id = OLD.session_id
|
|
161
|
+
AND e.type = 'agent:event'
|
|
162
|
+
AND json_valid(e.payload)
|
|
163
|
+
GROUP BY e.workspace_id, e.session_id;
|
|
164
|
+
END;
|
|
165
|
+
|
|
77
166
|
CREATE TABLE IF NOT EXISTS workspace_permission_rules (
|
|
78
167
|
id TEXT PRIMARY KEY,
|
|
79
168
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
@@ -140,6 +229,10 @@ export function initSchema(db) {
|
|
|
140
229
|
CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id);
|
|
141
230
|
CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_id ON agent_sessions(workspace_id);
|
|
142
231
|
CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_id ON ws_events(workspace_id);
|
|
232
|
+
CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_type_session
|
|
233
|
+
ON ws_events(workspace_id, type, session_id, created_at);
|
|
234
|
+
CREATE INDEX IF NOT EXISTS idx_ws_events_type_created
|
|
235
|
+
ON ws_events(type, created_at DESC, id DESC);
|
|
143
236
|
CREATE INDEX IF NOT EXISTS idx_workspace_chat_history_workspace_id_id
|
|
144
237
|
ON workspace_chat_history(workspace_id, id DESC);
|
|
145
238
|
`);
|
package/dist/server/index.js
CHANGED
|
@@ -406,12 +406,9 @@ setMessageHandler(async (type, payload) => {
|
|
|
406
406
|
}
|
|
407
407
|
}
|
|
408
408
|
if (type === 'devserver:stop' && p?.workspaceId) {
|
|
409
|
-
|
|
410
|
-
stopDevServer(p.workspaceId);
|
|
411
|
-
}
|
|
412
|
-
catch (err) {
|
|
409
|
+
void stopDevServer(p.workspaceId).catch((err) => {
|
|
413
410
|
console.error('[ws] Failed to stop dev-server:', err);
|
|
414
|
-
}
|
|
411
|
+
});
|
|
415
412
|
}
|
|
416
413
|
});
|
|
417
414
|
// Handle WebSocket upgrade requests on /ws path
|
|
@@ -451,16 +448,22 @@ server.on('upgrade', (request, socket, head) => {
|
|
|
451
448
|
});
|
|
452
449
|
// Graceful shutdown handler
|
|
453
450
|
let isShuttingDown = false;
|
|
454
|
-
function gracefulShutdown(signal) {
|
|
451
|
+
async function gracefulShutdown(signal) {
|
|
455
452
|
if (isShuttingDown)
|
|
456
453
|
return;
|
|
457
454
|
isShuttingDown = true;
|
|
458
455
|
console.log(`\n[kobo] Received ${signal}, shutting down gracefully…`);
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
});
|
|
463
|
-
|
|
456
|
+
const forceExitTimer = setTimeout(() => {
|
|
457
|
+
console.error('[kobo] Graceful shutdown timed out; forcing exit');
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}, 5_000);
|
|
460
|
+
forceExitTimer.unref();
|
|
461
|
+
// Ask WebSocket clients to leave so their upgraded HTTP connections do not
|
|
462
|
+
// keep server.close() waiting indefinitely.
|
|
463
|
+
for (const client of wss.clients)
|
|
464
|
+
client.close(1001, 'Server shutting down');
|
|
465
|
+
for (const client of terminalWss.clients)
|
|
466
|
+
client.close(1001, 'Server shutting down');
|
|
464
467
|
try {
|
|
465
468
|
destroyAllTerminals();
|
|
466
469
|
console.log('[kobo] Terminals killed');
|
|
@@ -468,9 +471,6 @@ function gracefulShutdown(signal) {
|
|
|
468
471
|
catch {
|
|
469
472
|
// Best-effort
|
|
470
473
|
}
|
|
471
|
-
server.close(() => {
|
|
472
|
-
console.log('[kobo] HTTP server closed');
|
|
473
|
-
});
|
|
474
474
|
// Stop background services
|
|
475
475
|
try {
|
|
476
476
|
stopWatchdog();
|
|
@@ -498,19 +498,28 @@ function gracefulShutdown(signal) {
|
|
|
498
498
|
catch {
|
|
499
499
|
// Best-effort
|
|
500
500
|
}
|
|
501
|
-
//
|
|
501
|
+
// Stop accepting new HTTP/WS work, then let in-flight handlers finish before
|
|
502
|
+
// closing SQLite. Closing the DB earlier makes those handlers fail midway.
|
|
502
503
|
try {
|
|
503
|
-
|
|
504
|
-
|
|
504
|
+
await Promise.all([
|
|
505
|
+
new Promise((resolve) => server.close(() => resolve())),
|
|
506
|
+
new Promise((resolve) => wss.close(() => resolve())),
|
|
507
|
+
new Promise((resolve) => terminalWss.close(() => resolve())),
|
|
508
|
+
]);
|
|
509
|
+
console.log('[kobo] HTTP and WebSocket servers closed');
|
|
505
510
|
}
|
|
506
|
-
|
|
507
|
-
|
|
511
|
+
finally {
|
|
512
|
+
try {
|
|
513
|
+
closeDb();
|
|
514
|
+
console.log('[kobo] Database closed');
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// Best-effort
|
|
518
|
+
}
|
|
508
519
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
process.exit(0);
|
|
513
|
-
}, 2000).unref();
|
|
520
|
+
clearTimeout(forceExitTimer);
|
|
521
|
+
console.log('[kobo] Shutdown complete');
|
|
522
|
+
process.exit(0);
|
|
514
523
|
}
|
|
515
|
-
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
516
|
-
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
524
|
+
process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));
|
|
525
|
+
process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
|
|
@@ -6,7 +6,7 @@ import { getWorkspace } from '../services/workspace-service.js';
|
|
|
6
6
|
/** Hono sub-router for per-workspace dev server lifecycle (start, stop, status, logs). */
|
|
7
7
|
const app = new Hono();
|
|
8
8
|
// GET /api/dev-server/:workspaceId/status
|
|
9
|
-
app.get('/:workspaceId/status', (c) => {
|
|
9
|
+
app.get('/:workspaceId/status', async (c) => {
|
|
10
10
|
try {
|
|
11
11
|
const workspaceId = c.req.param('workspaceId');
|
|
12
12
|
const workspace = getWorkspace(workspaceId);
|
|
@@ -26,7 +26,7 @@ app.get('/:workspaceId/status', (c) => {
|
|
|
26
26
|
containers: [],
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
-
const status = getStatus(workspace.projectPath, workspace.workingBranch, workspaceId);
|
|
29
|
+
const status = await getStatus(workspace.projectPath, workspace.workingBranch, workspaceId);
|
|
30
30
|
// If runtime detection returns unknown, use persisted status from DB
|
|
31
31
|
if (status.status === 'unknown' && workspace.devServerStatus && workspace.devServerStatus !== 'stopped') {
|
|
32
32
|
status.status = workspace.devServerStatus;
|
|
@@ -56,14 +56,14 @@ app.post('/:workspaceId/start', migrationGuard, (c) => {
|
|
|
56
56
|
}
|
|
57
57
|
});
|
|
58
58
|
// POST /api/dev-server/:workspaceId/stop
|
|
59
|
-
app.post('/:workspaceId/stop', migrationGuard, (c) => {
|
|
59
|
+
app.post('/:workspaceId/stop', migrationGuard, async (c) => {
|
|
60
60
|
try {
|
|
61
61
|
const workspaceId = c.req.param('workspaceId');
|
|
62
62
|
const workspace = getWorkspace(workspaceId);
|
|
63
63
|
if (!workspace) {
|
|
64
64
|
return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
|
|
65
65
|
}
|
|
66
|
-
const status = stopDevServer(workspaceId);
|
|
66
|
+
const status = await stopDevServer(workspaceId);
|
|
67
67
|
return c.json(status);
|
|
68
68
|
}
|
|
69
69
|
catch (err) {
|
|
@@ -72,15 +72,16 @@ app.post('/:workspaceId/stop', migrationGuard, (c) => {
|
|
|
72
72
|
}
|
|
73
73
|
});
|
|
74
74
|
// GET /api/dev-server/:workspaceId/logs
|
|
75
|
-
app.get('/:workspaceId/logs', (c) => {
|
|
75
|
+
app.get('/:workspaceId/logs', async (c) => {
|
|
76
76
|
try {
|
|
77
77
|
const workspaceId = c.req.param('workspaceId');
|
|
78
78
|
const workspace = getWorkspace(workspaceId);
|
|
79
79
|
if (!workspace) {
|
|
80
80
|
return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
|
|
81
81
|
}
|
|
82
|
-
const
|
|
83
|
-
const
|
|
82
|
+
const parsedTail = parseInt(c.req.query('tail') ?? '200', 10);
|
|
83
|
+
const tail = Math.max(1, Math.min(Number.isFinite(parsedTail) ? parsedTail : 200, 1000));
|
|
84
|
+
const logs = await getDevServerLogs(workspaceId, tail);
|
|
84
85
|
return c.json({ logs });
|
|
85
86
|
}
|
|
86
87
|
catch (err) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync,
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
4
|
import * as workspaceService from '../services/workspace-service.js';
|
|
5
|
+
import { isPathInside, resolveExistingPathInside } from '../utils/safe-path.js';
|
|
5
6
|
/** Hono sub-router for workspace document browsing (read-only). */
|
|
6
7
|
const app = new Hono();
|
|
7
8
|
/**
|
|
@@ -17,6 +18,7 @@ const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts'];
|
|
|
17
18
|
const MD_EXT = '.md';
|
|
18
19
|
/** Depth cap to keep recursion bounded even on pathological symlink loops. */
|
|
19
20
|
const MAX_DEPTH = 8;
|
|
21
|
+
const MAX_DOCUMENT_SIZE = 1024 * 1024;
|
|
20
22
|
function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
|
|
21
23
|
if (depth > MAX_DEPTH)
|
|
22
24
|
return;
|
|
@@ -34,11 +36,13 @@ function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
|
|
|
34
36
|
const relEntry = `${rootRel}/${entry}`;
|
|
35
37
|
let stat;
|
|
36
38
|
try {
|
|
37
|
-
stat =
|
|
39
|
+
stat = lstatSync(absEntry);
|
|
38
40
|
}
|
|
39
41
|
catch {
|
|
40
42
|
continue;
|
|
41
43
|
}
|
|
44
|
+
if (stat.isSymbolicLink())
|
|
45
|
+
continue;
|
|
42
46
|
if (stat.isDirectory()) {
|
|
43
47
|
walkMarkdownFiles(absEntry, relEntry, out, depth + 1);
|
|
44
48
|
}
|
|
@@ -65,6 +69,15 @@ app.get('/:id/documents', (c) => {
|
|
|
65
69
|
const absDir = path.join(worktreePath, dir);
|
|
66
70
|
if (!existsSync(absDir))
|
|
67
71
|
continue;
|
|
72
|
+
const dirStat = lstatSync(absDir);
|
|
73
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory())
|
|
74
|
+
continue;
|
|
75
|
+
try {
|
|
76
|
+
resolveExistingPathInside(worktreePath, absDir);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
68
81
|
walkMarkdownFiles(absDir, dir, documents);
|
|
69
82
|
}
|
|
70
83
|
// Sort by modifiedAt descending (most recent first)
|
|
@@ -102,7 +115,24 @@ app.get('/:id/document', (c) => {
|
|
|
102
115
|
if (!existsSync(absPath)) {
|
|
103
116
|
return c.json({ error: `Document not found: ${normalized}` }, 404);
|
|
104
117
|
}
|
|
105
|
-
const
|
|
118
|
+
const fileStat = lstatSync(absPath);
|
|
119
|
+
if (fileStat.isSymbolicLink())
|
|
120
|
+
return c.json({ error: 'Symbolic links are not allowed' }, 400);
|
|
121
|
+
if (!fileStat.isFile())
|
|
122
|
+
return c.json({ error: `Document not found: ${normalized}` }, 404);
|
|
123
|
+
const realWorktree = realpathSync(worktreePath);
|
|
124
|
+
const realFile = resolveExistingPathInside(worktreePath, absPath);
|
|
125
|
+
const allowed = DOCUMENT_DIRS.some((dir) => {
|
|
126
|
+
const allowedRoot = path.join(worktreePath, dir);
|
|
127
|
+
if (!existsSync(allowedRoot) || lstatSync(allowedRoot).isSymbolicLink())
|
|
128
|
+
return false;
|
|
129
|
+
return isPathInside(realpathSync(allowedRoot), realFile);
|
|
130
|
+
});
|
|
131
|
+
if (!isPathInside(realWorktree, realFile) || !allowed)
|
|
132
|
+
return c.json({ error: 'Path escapes allowed root' }, 400);
|
|
133
|
+
if (fileStat.size > MAX_DOCUMENT_SIZE)
|
|
134
|
+
return c.json({ error: 'Document exceeds the 1 MB size limit' }, 413);
|
|
135
|
+
const content = readFileSync(realFile, 'utf-8');
|
|
106
136
|
return c.json({ content, path: normalized });
|
|
107
137
|
}
|
|
108
138
|
catch (err) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
3
4
|
import { Hono } from 'hono';
|
|
4
5
|
import { getDb } from '../db/index.js';
|
|
5
6
|
import { SCHEMA_VERSION } from '../db/migrations.js';
|
|
@@ -9,24 +10,22 @@ import { getDbPath, getKoboHome, getPackageVersion } from '../utils/paths.js';
|
|
|
9
10
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
10
11
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
11
12
|
const app = new Hono();
|
|
12
|
-
|
|
13
|
+
const execFileAsync = promisify(execFileCb);
|
|
14
|
+
async function checkCli(binary) {
|
|
13
15
|
try {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
return { available: false, version: null };
|
|
17
|
-
return { available: true, version: (r.stdout ?? '').trim() || null };
|
|
16
|
+
const { stdout } = await execFileAsync(binary, ['--version'], { encoding: 'utf-8', timeout: 2_000 });
|
|
17
|
+
return { available: true, version: stdout.trim() || null };
|
|
18
18
|
}
|
|
19
19
|
catch {
|
|
20
20
|
return { available: false, version: null };
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
function
|
|
23
|
+
function checkClaudeCli() {
|
|
24
|
+
return checkCli('claude');
|
|
25
|
+
}
|
|
26
|
+
async function checkCodexCli() {
|
|
24
27
|
try {
|
|
25
|
-
|
|
26
|
-
const r = spawnSync(bin, ['--version'], { encoding: 'utf-8' });
|
|
27
|
-
if (r.error || r.status !== 0)
|
|
28
|
-
return { available: false, version: null };
|
|
29
|
-
return { available: true, version: (r.stdout ?? '').trim() || null };
|
|
28
|
+
return await checkCli(resolveCodexBinary());
|
|
30
29
|
}
|
|
31
30
|
catch {
|
|
32
31
|
return { available: false, version: null };
|
|
@@ -50,10 +49,11 @@ function safeFileSize(p) {
|
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
// GET /api/health/report — detailed health diagnostics for the Health panel.
|
|
53
|
-
app.get('/report', (c) => {
|
|
52
|
+
app.get('/report', async (c) => {
|
|
54
53
|
const db = getDb();
|
|
55
54
|
const dbPath = getDbPath();
|
|
56
55
|
const home = getKoboHome();
|
|
56
|
+
const cliChecks = Promise.all([checkClaudeCli(), checkCodexCli()]);
|
|
57
57
|
// DB schema version
|
|
58
58
|
const row = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get();
|
|
59
59
|
const dbSchemaVersion = row?.v ?? 0;
|
|
@@ -145,6 +145,7 @@ app.get('/report', (c) => {
|
|
|
145
145
|
.all();
|
|
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
|
+
const [claudeCli, codexCli] = await cliChecks;
|
|
148
149
|
const report = {
|
|
149
150
|
version: getPackageVersion(),
|
|
150
151
|
koboHome: home,
|
|
@@ -155,8 +156,8 @@ app.get('/report', (c) => {
|
|
|
155
156
|
currentSchemaVersion: SCHEMA_VERSION,
|
|
156
157
|
},
|
|
157
158
|
settings: { schemaVersion: SETTINGS_SCHEMA_VERSION },
|
|
158
|
-
claudeCli
|
|
159
|
-
codexCli
|
|
159
|
+
claudeCli,
|
|
160
|
+
codexCli,
|
|
160
161
|
workspaces: {
|
|
161
162
|
total: settingsRow.n,
|
|
162
163
|
archived: archivedRow.n,
|