@loicngr/kobo 1.7.4 → 1.7.6
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/README.md +26 -9
- package/dist/mcp-server/kobo-tasks-handlers.js +41 -1
- package/dist/mcp-server/kobo-tasks-server.js +157 -8
- package/dist/server/db/migrations.js +87 -0
- package/dist/server/db/schema.js +27 -0
- package/dist/server/index.js +9 -1
- package/dist/server/routes/health.js +68 -3
- package/dist/server/routes/workspaces.js +183 -4
- package/dist/server/services/agent/engines/claude-code/engine.js +13 -6
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +96 -7
- package/dist/server/services/agent/orchestrator.js +113 -71
- package/dist/server/services/auto-loop-service.js +16 -3
- package/dist/server/services/cron-service.js +279 -0
- package/dist/server/services/quota-backoff-service.js +127 -0
- package/dist/server/services/wakeup-service.js +1 -1
- package/dist/server/services/workspace-service.js +98 -0
- package/dist/server/utils/git-ops.js +8 -1
- package/package.json +2 -1
- package/src/client/dist/spa/assets/{ActivityFeed-ClJLeAXJ.js → ActivityFeed-BboSPm4b.js} +2 -2
- package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
- package/src/client/dist/spa/assets/AutoLoopChip-w8D77bI5.js +1 -0
- package/src/client/dist/spa/assets/{CreatePage-BOkt0Psl.js → CreatePage-BDObLDJc.js} +1 -1
- package/src/client/dist/spa/assets/{DiffViewer-Dls1jFCN.js → DiffViewer-CblFgn8w.js} +3 -3
- package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
- package/src/client/dist/spa/assets/HealthPage-CBSw7e5q.js +1 -0
- package/src/client/dist/spa/assets/{MainLayout-DHNIerYJ.js → MainLayout-DhaYycak.js} +17 -17
- package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
- package/src/client/dist/spa/assets/{SearchPage-BEnZ-CLq.js → SearchPage-cZTwP4Lf.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-DeCbWvPb.js → SettingsPage-C1efO0VM.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-3jcof896.js +4 -0
- package/src/client/dist/spa/assets/{WorkspacePage-eymEd4kx.css → WorkspacePage-CCtIrBiR.css} +1 -1
- package/src/client/dist/spa/assets/{cssMode-AlflsawW.js → cssMode-BFLYiiEw.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-DtvjQlUm.js → editor.api-2asmmhth.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-Ccy_gjVD.js → editor.main-ChCYZyez.js} +3 -3
- package/src/client/dist/spa/assets/{expand-template-AQsvbQ8_.js → expand-template-CXQFkQOJ.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-DdQktlXK.js → freemarker2-BaBL9E9G.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CE3ee2NH.js → handlebars-BxDour4L.js} +1 -1
- package/src/client/dist/spa/assets/{html-CCKX8Xv9.js → html-C6hnkfIL.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-Dh8jDJum.js → htmlMode-9zT3-dmz.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CLY0XI9-.js +1 -0
- package/src/client/dist/spa/assets/index-D6wj_wQ9.js +2 -0
- package/src/client/dist/spa/assets/{javascript-DhmZNdUp.js → javascript-C3YjvKbE.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-B0xAtnNK.js → jsonMode-DcJDgMzf.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-ByL0HpZ0.js → liquid-CsT8SjJM.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-DX4pehAZ.js → mdx-CT3yVSyc.js} +1 -1
- package/src/client/dist/spa/assets/{models-ClWoqWeC.js → models-BsjWUKqM.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Fegh8Y1Y.js → monaco.contribution-DKGNz1oQ.js} +2 -2
- package/src/client/dist/spa/assets/{purify.es-BWZjBa9F.js → purify.es-CPieV82n.js} +1 -1
- package/src/client/dist/spa/assets/{python-COS2MM8n.js → python-Ca5miKgj.js} +1 -1
- package/src/client/dist/spa/assets/{razor-Cc3xCJU7.js → razor-7qzusGRc.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-DcGIpMoe.js → render-chat-markdown-Bqq2G-yI.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-eQIJjERk.js → tsMode-BdvO8jZ2.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-DwIlacVU.js → typescript-BfVNzhgs.js} +1 -1
- package/src/client/dist/spa/assets/{xml-DP-09Aih.js → xml-DGNXGqXL.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-BhrtimeA.js → yaml-CtAtOyt5.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +55 -1
- package/src/mcp-server/kobo-tasks-server.ts +165 -7
- package/src/client/dist/spa/assets/HealthPage-CMxH3SBS.js +0 -1
- package/src/client/dist/spa/assets/MainLayout-DKurmqtk.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-DFAFT5OW.js +0 -4
- package/src/client/dist/spa/assets/i18n-BOsrrRj4.js +0 -1
- package/src/client/dist/spa/assets/index-_ZaIBxd6.js +0 -2
- /package/src/client/dist/spa/assets/{QPage-ChUKoaKe.js → QPage-DFi3K093.js} +0 -0
- /package/src/client/dist/spa/assets/{formatters-BD0_hovB.js → formatters-DCAQ6ANJ.js} +0 -0
package/README.md
CHANGED
|
@@ -31,10 +31,13 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
|
|
|
31
31
|
- **Resizable right drawer** — drag-to-resize horizontally and vertically, with tab state and split ratio persisted to localStorage
|
|
32
32
|
- **Soft interrupt** — pause an agent mid-execution (SIGINT, like pressing Escape in Claude Code) without killing the process; the agent stops the current tool and waits for the next message
|
|
33
33
|
- **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
|
|
34
|
-
- **Auto-loop mode** — opt-in, per-workspace: when enabled, Kōbō spawns a fresh Claude session for the next pending task after every `session:ended`, walking through the task list until all are `done`. Stops automatically on error, on stall (3 consecutive sessions with no task completed), or when the user clicks Stop. A grooming step (`/kobo-prep-autoloop`) ensures tasks are atomic before the loop runs; Notion-imported workspaces with both todos and acceptance criteria are auto-unlocked. **E2E grooming** — when a project declares an E2E framework in Settings (Cypress, Playwright, Vitest, etc.), the grooming phase injects an `[E2E] ` test sub-task between every parent task; each iteration then runs the matching E2E suite as part of its acceptance check
|
|
34
|
+
- **Auto-loop mode** — opt-in, per-workspace: when enabled, Kōbō spawns a fresh Claude session for the next pending task after every `session:ended`, walking through the task list until all are `done`. Stops automatically on error, on stall (3 consecutive sessions with no task completed), or when the user clicks Stop. A grooming step (`/kobo-prep-autoloop`) ensures tasks are atomic before the loop runs; Notion-imported workspaces with both todos and acceptance criteria are auto-unlocked. **E2E grooming** — when a project declares an E2E framework in Settings (Cypress, Playwright, Vitest, etc.), the grooming phase injects an `[E2E] ` test sub-task between every parent task; each iteration then runs the matching E2E suite as part of its acceptance check. **Sidebar progress chip** — auto-loop workspaces show an `X / Y tâches` badge in the workspace list, fed by the same task counts used by the in-page chip
|
|
35
35
|
- **Attach existing worktrees** — Kōbō detects orphan git worktrees for the selected project (created outside Kōbō, or left over from an earlier install) and lets you attach them to a new workspace from the creation form, picking up the existing branch and folder instead of cloning a new one
|
|
36
|
-
- **
|
|
37
|
-
- **
|
|
36
|
+
- **Persistent quota backoff** — when a Claude rate limit is hit mid-session, Kōbō schedules the retry at the actual reset time reported by the API (via `rate_limit.info.buckets[].resetsAt`), falling back to the OAuth usage poller, then to a 15 → 30 → 60 → 180 → 300 min ladder when both are missing. The pending backoff is **persisted in SQLite** and re-armed on server restart, so nothing is lost if the host reboots mid-window. A live banner counts down to the reset and lets the user cancel the wait. Only auto-loop workspaces resume automatically — others stay in `quota` status awaiting a manual nudge
|
|
37
|
+
- **Workspace description fields** — every workspace has TWO independent description fields. The user-side `description` is editable via the header input or right-click **Modifier la description**, and stays under the user's control (the agent cannot overwrite it). The agent maintains its own `agent_description` via the `set_workspace_agent_description` MCP tool to broadcast a live status (e.g. "Investigating SERVICE-1600 → enriching local Notion file"). Both are visible: the sidebar shows `agent_description` when set, falling back to the user `description`; the workspace header shows the user input plus an italic read-only line for the agent's current focus
|
|
38
|
+
- **Scheduled wakeups** — the `ScheduleWakeup` tool is honoured server-side: Kōbō persists the wakeup in SQLite, rehydrates on restart, and respawns the agent with `--resume` at the target time. Delay is clamped to `[60s, 6h]`. The kobo-tasks MCP server also exposes `kobo__schedule_wakeup` / `kobo__cancel_wakeup` for the same flow with first-class tool descriptors
|
|
39
|
+
- **Recurring & one-shot crons** — agents schedule recurring triggers via `kobo__cron_create(expression, prompt, label?, mode?, oneShot?)`. Standard 5-field cron expressions (`*/30 * * * *`) plus helpers (`@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`). Two modes: `'resume'` (default) pins the cron to the session that scheduled it so each fire continues that conversation; `'fresh'` spawns a brand-new session per fire (clean context, ideal for periodic CI / dashboard checks). `oneShot: true` cancels the cron after the first real fire — useful for "trigger once at a specific date/time" without recurring. Crons are persisted in SQLite, re-armed on restart with skip-missed semantics (no catchup spam after downtime), and skip-if-active when a session is already running. Multiple crons per workspace. The native Claude Code `CronCreate` tool is also intercepted and mirrored as a kobo cron so even agents using the SDK-default tool benefit from persistence
|
|
40
|
+
- **Schedule panel in the right drawer** — dedicated tab listing every wakeup and cron currently armed for the focused workspace, with their next/last fire times, prompt preview, and a `×` button to cancel inline. Sidebar workspace cards display an `event_repeat` icon when one or more crons are scheduled, even for workspaces not currently focused — broadcast live via WebSocket events
|
|
38
41
|
|
|
39
42
|
## Tech stack
|
|
40
43
|
|
|
@@ -101,8 +104,8 @@ npm start # runs the compiled server
|
|
|
101
104
|
### Test & lint
|
|
102
105
|
|
|
103
106
|
```bash
|
|
104
|
-
npm test # backend vitest suite (
|
|
105
|
-
npm run test:client # client vitest suite (Pinia stores + pure utils,
|
|
107
|
+
npm test # backend vitest suite (1200+ tests)
|
|
108
|
+
npm run test:client # client vitest suite (Pinia stores + pure utils, 230+ tests)
|
|
106
109
|
npm run test:all # backend + client suites
|
|
107
110
|
npm run lint # biome check (lint + format verification)
|
|
108
111
|
npm run lint:fix # biome check with safe auto-fixes
|
|
@@ -229,6 +232,9 @@ src/
|
|
|
229
232
|
│ │ │ └── engines/claude-code/ # spawn + NDJSON stream-parser + args-builder + mcp-config + capabilities
|
|
230
233
|
│ │ ├── content-migration-service.ts # legacy ws_events → normalised AgentEvent rows, with DB backup
|
|
231
234
|
│ │ ├── usage/ # pluggable quota provider, 60s poller, persistence, WS broadcast
|
|
235
|
+
│ │ ├── quota-backoff-service.ts # persisted Claude rate-limit backoff timers (re-armed on restart)
|
|
236
|
+
│ │ ├── cron-service.ts # persisted cron schedules (recurring + one-shot, resume/fresh modes)
|
|
237
|
+
│ │ ├── wakeup-service.ts # persisted one-shot session resumes (clamped to [60s, 6h])
|
|
232
238
|
│ │ └── … # workspace, dev-server, ws, notion, sentry, settings, pr-template
|
|
233
239
|
│ ├── routes/ # Hono handlers (workspaces, engines, migration, templates, usage, …)
|
|
234
240
|
│ └── utils/ # git-ops, process-tracker, paths
|
|
@@ -252,18 +258,29 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
|
|
|
252
258
|
|
|
253
259
|
| Table | Purpose |
|
|
254
260
|
|---|---|
|
|
255
|
-
| `workspaces` | the unit of work — branch, `worktree_path`, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, … |
|
|
261
|
+
| `workspaces` | the unit of work — branch, `worktree_path`, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, plus the two independent description columns (`description` user-controlled, `agent_description` agent-controlled), … |
|
|
256
262
|
| `tasks` | workspace sub-items — tasks and acceptance criteria |
|
|
257
263
|
| `agent_sessions` | agent runs — pid, `engine_session_id`, lifecycle |
|
|
258
264
|
| `ws_events` | persisted WebSocket events (chat history, `agent:event` stream, user messages) for replay on reconnect |
|
|
259
265
|
| `usage_snapshots` | latest quota snapshot per provider (one row per `provider_id`) — populated by the 60s polling loop, used for cold-start hydration of the chat-footer quota badge |
|
|
266
|
+
| `pending_wakeups` | one row per scheduled wakeup, target time and resume context, re-armed on server restart |
|
|
267
|
+
| `pending_quota_backoffs` | one row per workspace currently waiting on a Claude rate-limit reset, target time + reset metadata + retry count, re-armed on server restart |
|
|
268
|
+
| `pending_crons` | one row per scheduled cron — expression, prompt, label, optional pinned `agent_session_id` (= resume mode) or NULL (= fresh mode), `next_fire_at`, `last_fired_at`, `one_shot` flag, re-armed on server restart with skip-missed semantics |
|
|
260
269
|
|
|
261
270
|
## MCP server
|
|
262
271
|
|
|
263
|
-
Each workspace spawns its own `kobo-tasks` MCP server as a child process of the Claude Code agent. It exposes
|
|
272
|
+
Each workspace spawns its own `kobo-tasks` MCP server as a child process of the Claude Code agent. It exposes a curated tool surface tailored for agents working inside a Kōbō workspace, grouped by intent:
|
|
264
273
|
|
|
265
|
-
-
|
|
266
|
-
- `
|
|
274
|
+
- **Tasks** — `list_tasks`, `create_task`, `update_task`, `delete_task`, `mark_task_done`
|
|
275
|
+
- **Workspace metadata** — `get_workspace_info` (returns name, branch, status, both `description` and `agentDescription`, etc.), `set_workspace_agent_description` (live status the user sees in the sidebar without opening the workspace), `set_workspace_status`
|
|
276
|
+
- **Auto-loop** — `mark_auto_loop_ready` (flip the grooming gate when the brainstorm step is done)
|
|
277
|
+
- **Git** — `get_git_info` (branch, commit count, dirty state)
|
|
278
|
+
- **Dev server** — `get_dev_server_status`, `start_dev_server`, `stop_dev_server`, `get_dev_server_logs`
|
|
279
|
+
- **External sources** — `get_notion_ticket`, `get_settings`
|
|
280
|
+
- **Documents & search** — `list_documents`, `read_document`, `log_thought`, `search_codebase`, `list_workspace_images`
|
|
281
|
+
- **Scheduling & telemetry** — `schedule_wakeup`, `cancel_wakeup`, `cron_create`, `cron_delete`, `cron_list`, `get_session_usage`
|
|
282
|
+
|
|
283
|
+
State-mutating tools that change UI-visible data (tasks, agent description, auto-loop readiness) write directly to SQLite for low latency, then fire-and-forget a `notify-*` HTTP call to the backend so the WebSocket layer broadcasts the change to every connected client. Tools that arm in-memory timers (`cron_create`, `cron_delete`) route through the backend HTTP API instead — the timer Map lives in the backend process which owns the orchestrator, so the cron survives the MCP server's session-bound lifetime.
|
|
267
284
|
|
|
268
285
|
The MCP server reads and writes the same SQLite database as the main backend. Isolation between workspaces is enforced via the `KOBO_WORKSPACE_ID` environment variable passed at spawn time and validated on every query.
|
|
269
286
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { nanoid } from 'nanoid';
|
|
4
|
+
import * as cronService from '../server/services/cron-service.js';
|
|
4
5
|
import * as settingsService from '../server/services/settings-service.js';
|
|
5
6
|
import { slugifyProjectName } from '../server/utils/project-slug.js';
|
|
6
7
|
import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
|
|
@@ -37,6 +38,29 @@ export function markAutoLoopReadyHandler(db, workspaceId) {
|
|
|
37
38
|
db.prepare('UPDATE workspaces SET auto_loop_ready = 1 WHERE id = ?').run(workspaceId);
|
|
38
39
|
return { ok: true };
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Update the workspace's agent-side short description (≤ 200 chars). Empty /
|
|
43
|
+
* whitespace-only input clears the field (stored as NULL). This writes the
|
|
44
|
+
* `agent_description` column — a live status line owned by the agent — and
|
|
45
|
+
* leaves the user-side `description` column untouched. The handler does NOT
|
|
46
|
+
* emit a WS event; that's the route/service layer's responsibility. Live UI
|
|
47
|
+
* refresh on agent-driven updates is therefore deferred until next read.
|
|
48
|
+
*/
|
|
49
|
+
export function setWorkspaceAgentDescriptionHandler(db, workspaceId, args) {
|
|
50
|
+
const raw = typeof args?.description === 'string' ? args.description : '';
|
|
51
|
+
const trimmed = raw.trim();
|
|
52
|
+
if (trimmed.length > 200) {
|
|
53
|
+
return { ok: false, error: `Description must be 200 characters or fewer (got ${trimmed.length})` };
|
|
54
|
+
}
|
|
55
|
+
const stored = trimmed.length > 0 ? trimmed : null;
|
|
56
|
+
const result = db
|
|
57
|
+
.prepare('UPDATE workspaces SET agent_description = ?, updated_at = ? WHERE id = ?')
|
|
58
|
+
.run(stored, new Date().toISOString(), workspaceId);
|
|
59
|
+
if (result.changes === 0) {
|
|
60
|
+
return { ok: false, error: `Workspace '${workspaceId}' not found` };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, description: stored };
|
|
63
|
+
}
|
|
40
64
|
/** Set a task's status to "done" and return the updated task. */
|
|
41
65
|
export function markTaskDoneHandler(db, workspaceId, taskId) {
|
|
42
66
|
const now = new Date().toISOString();
|
|
@@ -155,7 +179,7 @@ export function getSettingsHandler(settingsPath, projectPath) {
|
|
|
155
179
|
/** Fetch workspace metadata from the database, computing the worktree path from project_path and working_branch. */
|
|
156
180
|
export function getWorkspaceInfoHandler(db, workspaceId) {
|
|
157
181
|
const row = db
|
|
158
|
-
.prepare('SELECT id, name, project_path, source_branch, working_branch, worktree_path, status, notion_url, notion_page_id, model, dev_server_status, has_unread, auto_loop, auto_loop_ready, created_at, updated_at FROM workspaces WHERE id = ?')
|
|
182
|
+
.prepare('SELECT id, name, project_path, source_branch, working_branch, worktree_path, status, notion_url, notion_page_id, description, agent_description, model, dev_server_status, has_unread, auto_loop, auto_loop_ready, created_at, updated_at FROM workspaces WHERE id = ?')
|
|
159
183
|
.get(workspaceId);
|
|
160
184
|
if (!row) {
|
|
161
185
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -176,6 +200,8 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
|
|
|
176
200
|
model: row.model,
|
|
177
201
|
notionUrl: row.notion_url,
|
|
178
202
|
notionPageId: row.notion_page_id,
|
|
203
|
+
description: row.description ?? null,
|
|
204
|
+
agentDescription: row.agent_description ?? null,
|
|
179
205
|
devServerStatus: row.dev_server_status,
|
|
180
206
|
hasUnread: row.has_unread === 1,
|
|
181
207
|
autoLoop: row.auto_loop === 1,
|
|
@@ -354,3 +380,17 @@ export function getSessionUsageHandler(db, workspaceId) {
|
|
|
354
380
|
currentSession: { sessionId: currentSessionId, ...current },
|
|
355
381
|
};
|
|
356
382
|
}
|
|
383
|
+
// ── Crons ────────────────────────────────────────────────────────────────────
|
|
384
|
+
/**
|
|
385
|
+
* List every cron currently armed for a workspace.
|
|
386
|
+
*
|
|
387
|
+
* Note: cron_create and cron_delete are NOT exposed as handlers — they route
|
|
388
|
+
* through the backend HTTP API (`POST/DELETE /api/workspaces/:id/crons`)
|
|
389
|
+
* because their `setTimeout` must live in the backend process (which owns
|
|
390
|
+
* the orchestrator). Handlers here would arm timers in the MCP sub-process,
|
|
391
|
+
* which dies with the agent session, and fires would never reach a real
|
|
392
|
+
* session resume. The list handler is a pure read so it's safe to keep local.
|
|
393
|
+
*/
|
|
394
|
+
export function cronListHandler(_db, workspaceId) {
|
|
395
|
+
return { ok: true, crons: cronService.listForWorkspace(workspaceId) };
|
|
396
|
+
}
|
|
@@ -4,8 +4,9 @@ import path from 'node:path';
|
|
|
4
4
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
6
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
7
|
+
import { getDb } from '../server/db/index.js';
|
|
8
|
+
import { runMigrations } from '../server/db/migrations.js';
|
|
9
|
+
import { createTaskHandler, cronListHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, setWorkspaceAgentDescriptionHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
|
|
9
10
|
const workspaceId = process.env.KOBO_WORKSPACE_ID;
|
|
10
11
|
const dbPath = process.env.KOBO_DB_PATH;
|
|
11
12
|
const settingsPath = process.env.KOBO_SETTINGS_PATH;
|
|
@@ -20,10 +21,21 @@ if (!dbPath) {
|
|
|
20
21
|
}
|
|
21
22
|
let db;
|
|
22
23
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// Use the shared `getDb` singleton so any service that calls `getDb()`
|
|
25
|
+
// internally (e.g. cron-service.arm) hits the SAME connection as this
|
|
26
|
+
// MCP server, against the SAME DB file. Without this bootstrap, the
|
|
27
|
+
// singleton would resolve via getDbPath() → getKoboHome() → KOBO_HOME
|
|
28
|
+
// env var, which the agent SDK does NOT pass to the MCP server (only
|
|
29
|
+
// KOBO_DB_PATH is passed). That would silently open a second connection
|
|
30
|
+
// against the user's prod DB at ~/.config/kobo/kobo.db, which may not
|
|
31
|
+
// even have the same schema as the dev DB the backend is using.
|
|
32
|
+
db = getDb(dbPath);
|
|
33
|
+
// Defensive: run migrations to ensure the schema is at the latest version.
|
|
34
|
+
// The backend already ran them at boot, but this MCP server might be the
|
|
35
|
+
// first to touch the DB (e.g. tests, race at first boot, or KOBO_DB_PATH
|
|
36
|
+
// pointing somewhere the backend hasn't migrated). Idempotent — no-op if
|
|
37
|
+
// the DB is already at SCHEMA_VERSION.
|
|
38
|
+
runMigrations(db);
|
|
27
39
|
}
|
|
28
40
|
catch (err) {
|
|
29
41
|
console.error('[kobo-tasks-server] Failed to open database:', err);
|
|
@@ -68,6 +80,22 @@ async function notifyAutoLoopReady() {
|
|
|
68
80
|
console.error('[kobo-tasks-server] notify-autoloop-ready failed:', err);
|
|
69
81
|
}
|
|
70
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Fire-and-forget POST that lands on `/agent-description/notify-updated`,
|
|
85
|
+
* which emits the `workspace:agent-description-updated` WS event so the
|
|
86
|
+
* sidebar fallback display + the workspace header italic line refresh live
|
|
87
|
+
* across every connected client. The handler already wrote the column to DB;
|
|
88
|
+
* this call is ONLY for the event emission.
|
|
89
|
+
*/
|
|
90
|
+
async function notifyAgentDescriptionUpdated() {
|
|
91
|
+
try {
|
|
92
|
+
const url = `${backendUrl}/api/workspaces/${workspaceId}/agent-description/notify-updated`;
|
|
93
|
+
await fetch(url, { method: 'POST' });
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error('[kobo-tasks-server] notify-agent-description-updated failed:', err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
71
99
|
/** Generic HTTP request to the Kobo backend, returning parsed JSON or null. */
|
|
72
100
|
async function backendRequest(method, pathname, body) {
|
|
73
101
|
const url = `${backendUrl}${pathname}`;
|
|
@@ -160,6 +188,67 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
160
188
|
description: 'CALL EARLY in a session to confirm project path, working/source branch, worktree path, model, and notion link. Cheap read — useful when the user refers to "this workspace" or when you need the worktree path to locate files.',
|
|
161
189
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
162
190
|
},
|
|
191
|
+
{
|
|
192
|
+
name: 'set_workspace_agent_description',
|
|
193
|
+
description: "Set or clear the workspace's agent-side description (≤ 200 chars). Pass an empty string to clear. The user sees this string under the workspace title in the sidebar (it takes precedence over the user-controlled `description` field). Plain text only. The current value is available via get_workspace_info as agentDescription. NOTE: there is a separate user-controlled `description` field — do NOT try to write it; it has no MCP tool.",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
description: {
|
|
198
|
+
type: 'string',
|
|
199
|
+
description: 'Plain text, max 200 characters. Empty string clears the description.',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
required: ['description'],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: 'cron_create',
|
|
207
|
+
description: 'Schedule a recurring trigger on THIS workspace. At each fire, Kōbō waits for the workspace to be idle (no active session) and then resumes the same conversation by injecting `prompt` as the next user message — same UX as `schedule_wakeup` but recurring. Skip-if-active: if a session is already running when the timer fires, that occurrence is skipped, the next occurrence is computed, and the cron continues. The cron persists across server restarts (skip-missed semantics on boot — no catchup spam). Delete with `cron_delete(id)`. Multiple crons per workspace are allowed.',
|
|
208
|
+
inputSchema: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
properties: {
|
|
211
|
+
expression: {
|
|
212
|
+
type: 'string',
|
|
213
|
+
description: 'Standard 5-field cron expression (`min hour dom month dow`) or one of the helpers `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`. Example: `*/30 * * * *` = every 30 minutes; `0 9 * * 1` = every Monday at 9am. Validated at create time.',
|
|
214
|
+
},
|
|
215
|
+
prompt: {
|
|
216
|
+
type: 'string',
|
|
217
|
+
description: 'The prompt to inject as the next user message at each fire.',
|
|
218
|
+
},
|
|
219
|
+
label: {
|
|
220
|
+
type: 'string',
|
|
221
|
+
description: 'Optional human-readable label for the cron (shown in the UI).',
|
|
222
|
+
},
|
|
223
|
+
mode: {
|
|
224
|
+
type: 'string',
|
|
225
|
+
enum: ['resume', 'fresh'],
|
|
226
|
+
description: "How each fire is handled. 'resume' (default) pins the cron to the session you're calling from, so every fire continues THAT conversation by injecting `prompt` as the next user message — use this when the cron should follow up on ongoing work. 'fresh' starts a brand-new session at every fire with a clean context — use this for periodic checks (e.g. CI watch, daily standup) that don't need conversation continuity.",
|
|
227
|
+
},
|
|
228
|
+
oneShot: {
|
|
229
|
+
type: 'boolean',
|
|
230
|
+
description: "When true, the cron cancels itself after the first real fire (default false = recurring). Use this to schedule a single trigger at a specific cron-expressible time (e.g. `0 14 7 6 *` = next 7 June at 14:00) without it repeating yearly. Skip-active fires don't consume the one-shot — the cron retries at the next occurrence until it actually runs once.",
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
required: ['expression', 'prompt'],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'cron_delete',
|
|
238
|
+
description: "Cancel a previously-armed cron by id. Idempotent — returns ok=true even if the id is unknown. Only the workspace's own crons can be cancelled (cron_list to see them).",
|
|
239
|
+
inputSchema: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: {
|
|
242
|
+
id: { type: 'string', description: 'The cron id returned by cron_create.' },
|
|
243
|
+
},
|
|
244
|
+
required: ['id'],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: 'cron_list',
|
|
249
|
+
description: 'List all crons currently armed on THIS workspace, including their next and last fire times.',
|
|
250
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
251
|
+
},
|
|
163
252
|
{
|
|
164
253
|
name: 'get_git_info',
|
|
165
254
|
description: 'CALL BEFORE creating a PR, committing in batches, or reporting progress to the user. Returns commit count ahead of source, files changed, insertions/deletions, and existing PR URL if any.',
|
|
@@ -294,13 +383,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
294
383
|
},
|
|
295
384
|
{
|
|
296
385
|
name: 'schedule_wakeup',
|
|
297
|
-
description: 'CALL to schedule a follow-up turn on THIS workspace after a delay. End the current turn normally; once it finishes and the workspace is idle, Kōbō waits `delaySeconds`, then resumes the same conversation by injecting `prompt` as the next user message. The wakeup is scoped to the current workspace and resumes its latest session — you cannot target another workspace or another session. If a turn is still active when the timer fires, the wakeup is skipped (status: `session-active`). Replaces any previously pending wakeup on this workspace. Delay is clamped to [60,
|
|
386
|
+
description: 'CALL to schedule a follow-up turn on THIS workspace after a delay. End the current turn normally; once it finishes and the workspace is idle, Kōbō waits `delaySeconds`, then resumes the same conversation by injecting `prompt` as the next user message. The wakeup is scoped to the current workspace and resumes its latest session — you cannot target another workspace or another session. If a turn is still active when the timer fires, the wakeup is skipped (status: `session-active`). Replaces any previously pending wakeup on this workspace. Delay is clamped to [60, 21600] seconds (1min to 6h). Prefer this over the built-in `ScheduleWakeup` tool — it is the SDK-supported entry point.',
|
|
298
387
|
inputSchema: {
|
|
299
388
|
type: 'object',
|
|
300
389
|
properties: {
|
|
301
390
|
delaySeconds: {
|
|
302
391
|
type: 'number',
|
|
303
|
-
description: 'Seconds from now until the wakeup fires. Clamped to [60,
|
|
392
|
+
description: 'Seconds from now until the wakeup fires. Clamped to [60, 21600] (1min to 6h).',
|
|
304
393
|
},
|
|
305
394
|
prompt: {
|
|
306
395
|
type: 'string',
|
|
@@ -396,6 +485,66 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
396
485
|
if (name === 'get_workspace_info') {
|
|
397
486
|
return ok(getWorkspaceInfoHandler(db, workspaceId));
|
|
398
487
|
}
|
|
488
|
+
if (name === 'set_workspace_agent_description') {
|
|
489
|
+
const description = a.description;
|
|
490
|
+
if (typeof description !== 'string')
|
|
491
|
+
return fail('description parameter is required');
|
|
492
|
+
const result = setWorkspaceAgentDescriptionHandler(db, workspaceId, { description });
|
|
493
|
+
if ('ok' in result && result.ok) {
|
|
494
|
+
void notifyAgentDescriptionUpdated();
|
|
495
|
+
}
|
|
496
|
+
return ok(result);
|
|
497
|
+
}
|
|
498
|
+
if (name === 'cron_create') {
|
|
499
|
+
const expression = a.expression;
|
|
500
|
+
const prompt = a.prompt;
|
|
501
|
+
const label = typeof a.label === 'string' ? a.label : undefined;
|
|
502
|
+
const mode = typeof a.mode === 'string' ? a.mode : undefined;
|
|
503
|
+
const oneShot = typeof a.oneShot === 'boolean' ? a.oneShot : undefined;
|
|
504
|
+
if (typeof expression !== 'string' || typeof prompt !== 'string') {
|
|
505
|
+
return fail('expression and prompt parameters are required');
|
|
506
|
+
}
|
|
507
|
+
if (mode !== undefined && mode !== 'resume' && mode !== 'fresh') {
|
|
508
|
+
return fail("mode must be 'resume' or 'fresh'");
|
|
509
|
+
}
|
|
510
|
+
// Route through the backend so the in-memory `setTimeout` lives in the
|
|
511
|
+
// backend process (which owns orchestrator + WS broadcast). Calling
|
|
512
|
+
// cron-service.arm() directly here would persist the row but arm the
|
|
513
|
+
// timer in the MCP server sub-process, which dies with the agent
|
|
514
|
+
// session — fires would never trigger a real session resume.
|
|
515
|
+
try {
|
|
516
|
+
const created = (await backendRequest('POST', `/api/workspaces/${workspaceId}/crons`, {
|
|
517
|
+
expression,
|
|
518
|
+
prompt,
|
|
519
|
+
label,
|
|
520
|
+
mode,
|
|
521
|
+
oneShot,
|
|
522
|
+
}));
|
|
523
|
+
return ok({ ok: true, cron: created.cron });
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
527
|
+
return ok({ ok: false, error: message });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (name === 'cron_delete') {
|
|
531
|
+
const id = a.id;
|
|
532
|
+
if (typeof id !== 'string')
|
|
533
|
+
return fail('id parameter is required');
|
|
534
|
+
// Same reason as cron_create — the backend owns the timer Map.
|
|
535
|
+
try {
|
|
536
|
+
await backendRequest('DELETE', `/api/workspaces/${workspaceId}/crons/${id}`);
|
|
537
|
+
return ok({ ok: true });
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
541
|
+
return ok({ ok: false, error: message });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (name === 'cron_list') {
|
|
545
|
+
const result = cronListHandler(db, workspaceId);
|
|
546
|
+
return ok(result);
|
|
547
|
+
}
|
|
399
548
|
if (name === 'start_dev_server') {
|
|
400
549
|
const result = await backendRequest('POST', `/api/dev-server/${workspaceId}/start`);
|
|
401
550
|
return ok(result);
|
|
@@ -194,6 +194,93 @@ export const migrations = [
|
|
|
194
194
|
db.prepare('ALTER TABLE pending_wakeups ADD COLUMN agent_session_id TEXT').run();
|
|
195
195
|
},
|
|
196
196
|
},
|
|
197
|
+
{
|
|
198
|
+
version: 19,
|
|
199
|
+
name: 'add-pending-quota-backoffs',
|
|
200
|
+
migrate: (db) => {
|
|
201
|
+
// Per-workspace quota-backoff scheduler: tracks the moment a workspace
|
|
202
|
+
// becomes eligible to retry after hitting a quota limit. Mirrors the
|
|
203
|
+
// shape of pending_wakeups (one row per workspace, FK CASCADE) but holds
|
|
204
|
+
// distinct fields (resets_at, source, retry_count) tied to the quota
|
|
205
|
+
// detection layer (rate-limit info, usage API, fallback ladder).
|
|
206
|
+
db.prepare(`CREATE TABLE IF NOT EXISTS pending_quota_backoffs (
|
|
207
|
+
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
208
|
+
target_at TEXT NOT NULL,
|
|
209
|
+
resets_at TEXT,
|
|
210
|
+
source TEXT NOT NULL CHECK (source IN ('rate_limit_info', 'usage_api', 'fallback_ladder')),
|
|
211
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
212
|
+
created_at TEXT NOT NULL
|
|
213
|
+
)`).run();
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
version: 20,
|
|
218
|
+
name: 'add-workspace-description',
|
|
219
|
+
migrate: (db) => {
|
|
220
|
+
// Free-form, optional summary of the mission shown in the sidebar and
|
|
221
|
+
// editable from the header. Nullable by design — pre-existing workspaces
|
|
222
|
+
// keep description = NULL until the user (or the brainstorm agent) sets
|
|
223
|
+
// one. Idempotent: skip the ALTER if the column is already present
|
|
224
|
+
// (covers re-runs and the case where a fresh-install ran initSchema first).
|
|
225
|
+
const cols = db.prepare('PRAGMA table_info(workspaces)').all();
|
|
226
|
+
if (cols.some((c) => c.name === 'description'))
|
|
227
|
+
return;
|
|
228
|
+
db.prepare('ALTER TABLE workspaces ADD COLUMN description TEXT').run();
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
version: 21,
|
|
233
|
+
name: 'add-workspace-agent-description',
|
|
234
|
+
migrate: (db) => {
|
|
235
|
+
// Agent-authored, optional summary of the mission written by the agent
|
|
236
|
+
// (typically at the end of brainstorm) via the renamed
|
|
237
|
+
// `set_workspace_agent_description` MCP tool. Distinct from the v20
|
|
238
|
+
// `description` column, which stays human-editable. Nullable, no default.
|
|
239
|
+
// Idempotent: skip the ALTER if the column already exists.
|
|
240
|
+
const cols = db.prepare('PRAGMA table_info(workspaces)').all();
|
|
241
|
+
if (cols.some((c) => c.name === 'agent_description'))
|
|
242
|
+
return;
|
|
243
|
+
db.prepare('ALTER TABLE workspaces ADD COLUMN agent_description TEXT').run();
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
version: 22,
|
|
248
|
+
name: 'add-pending-crons',
|
|
249
|
+
migrate: (db) => {
|
|
250
|
+
// Per-workspace cron schedules: each row arms a recurring agent prompt
|
|
251
|
+
// on a cron expression. Sibling timer table to pending_wakeups /
|
|
252
|
+
// pending_quota_backoffs. Many rows per workspace (unlike the one-row
|
|
253
|
+
// sibling tables), so id is the primary key. CASCADE on workspace
|
|
254
|
+
// delete to keep the timer set tidy. Idempotent via IF NOT EXISTS.
|
|
255
|
+
db.prepare(`CREATE TABLE IF NOT EXISTS pending_crons (
|
|
256
|
+
id TEXT PRIMARY KEY,
|
|
257
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
258
|
+
expression TEXT NOT NULL,
|
|
259
|
+
prompt TEXT NOT NULL,
|
|
260
|
+
label TEXT,
|
|
261
|
+
agent_session_id TEXT,
|
|
262
|
+
next_fire_at TEXT NOT NULL,
|
|
263
|
+
last_fired_at TEXT,
|
|
264
|
+
created_at TEXT NOT NULL
|
|
265
|
+
)`).run();
|
|
266
|
+
db.prepare('CREATE INDEX IF NOT EXISTS idx_pending_crons_workspace ON pending_crons(workspace_id)').run();
|
|
267
|
+
db.prepare('CREATE INDEX IF NOT EXISTS idx_pending_crons_next_fire ON pending_crons(next_fire_at)').run();
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
version: 23,
|
|
272
|
+
name: 'add-pending-crons-one-shot',
|
|
273
|
+
migrate: (db) => {
|
|
274
|
+
// One-shot cron: fires once and cancels itself. Distinct from a wakeup
|
|
275
|
+
// because it still uses cron expressions (can target an absolute date,
|
|
276
|
+
// e.g. `0 14 7 6 *` = "next 7 June at 14:00") rather than a delay.
|
|
277
|
+
// Default 0 (recurring) preserves existing behaviour.
|
|
278
|
+
const cols = db.prepare('PRAGMA table_info(pending_crons)').all();
|
|
279
|
+
if (!cols.some((c) => c.name === 'one_shot')) {
|
|
280
|
+
db.prepare('ALTER TABLE pending_crons ADD COLUMN one_shot INTEGER NOT NULL DEFAULT 0').run();
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
},
|
|
197
284
|
];
|
|
198
285
|
/** Current schema version — always equals the highest migration version. */
|
|
199
286
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -27,6 +27,8 @@ export function initSchema(db) {
|
|
|
27
27
|
no_progress_streak INTEGER NOT NULL DEFAULT 0,
|
|
28
28
|
permission_profile TEXT NOT NULL DEFAULT 'bypass',
|
|
29
29
|
agent_permission_mode TEXT NOT NULL DEFAULT 'bypass',
|
|
30
|
+
description TEXT,
|
|
31
|
+
agent_description TEXT,
|
|
30
32
|
created_at TEXT NOT NULL,
|
|
31
33
|
updated_at TEXT NOT NULL
|
|
32
34
|
);
|
|
@@ -71,6 +73,31 @@ export function initSchema(db) {
|
|
|
71
73
|
agent_session_id TEXT
|
|
72
74
|
);
|
|
73
75
|
|
|
76
|
+
CREATE TABLE IF NOT EXISTS pending_quota_backoffs (
|
|
77
|
+
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
78
|
+
target_at TEXT NOT NULL,
|
|
79
|
+
resets_at TEXT,
|
|
80
|
+
source TEXT NOT NULL CHECK (source IN ('rate_limit_info', 'usage_api', 'fallback_ladder')),
|
|
81
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
created_at TEXT NOT NULL
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS pending_crons (
|
|
86
|
+
id TEXT PRIMARY KEY,
|
|
87
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
88
|
+
expression TEXT NOT NULL,
|
|
89
|
+
prompt TEXT NOT NULL,
|
|
90
|
+
label TEXT,
|
|
91
|
+
agent_session_id TEXT,
|
|
92
|
+
next_fire_at TEXT NOT NULL,
|
|
93
|
+
last_fired_at TEXT,
|
|
94
|
+
one_shot INTEGER NOT NULL DEFAULT 0,
|
|
95
|
+
created_at TEXT NOT NULL
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_pending_crons_workspace ON pending_crons(workspace_id);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_pending_crons_next_fire ON pending_crons(next_fire_at);
|
|
100
|
+
|
|
74
101
|
CREATE TABLE IF NOT EXISTS usage_snapshots (
|
|
75
102
|
provider_id TEXT PRIMARY KEY,
|
|
76
103
|
status TEXT NOT NULL,
|
package/dist/server/index.js
CHANGED
|
@@ -20,12 +20,14 @@ import settingsRouter from './routes/settings.js';
|
|
|
20
20
|
import templatesRouter from './routes/templates.js';
|
|
21
21
|
import usageRoutes from './routes/usage.js';
|
|
22
22
|
import workspacesRouter from './routes/workspaces.js';
|
|
23
|
-
import { getAvailableSkills, reconcileOrphanSessions, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
23
|
+
import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
24
24
|
import * as autoLoopService from './services/auto-loop-service.js';
|
|
25
25
|
import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
|
|
26
|
+
import * as cronService from './services/cron-service.js';
|
|
26
27
|
import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
|
|
27
28
|
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
28
29
|
import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
|
|
30
|
+
import * as quotaBackoffService from './services/quota-backoff-service.js';
|
|
29
31
|
import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
|
|
30
32
|
import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
|
|
31
33
|
import * as wakeupService from './services/wakeup-service.js';
|
|
@@ -54,6 +56,12 @@ reconcileOrphanSessions();
|
|
|
54
56
|
startWatchdog();
|
|
55
57
|
wakeupService.rehydrate();
|
|
56
58
|
autoLoopService.rehydrate();
|
|
59
|
+
// Restore in-memory retry counts BEFORE re-arming the persisted backoff timers,
|
|
60
|
+
// otherwise the next arm() after restart would compute the next ladder rung
|
|
61
|
+
// from retryCount=0 and undo the progression.
|
|
62
|
+
restoreRetryCountsFromDb();
|
|
63
|
+
quotaBackoffService.restoreOnBoot((workspaceId) => autoLoopService.onQuotaBackoffExpired(workspaceId));
|
|
64
|
+
cronService.restoreOnBoot();
|
|
57
65
|
startPrWatcher();
|
|
58
66
|
startUsagePoller();
|
|
59
67
|
// Create Hono app
|
|
@@ -63,15 +63,73 @@ app.get('/report', (c) => {
|
|
|
63
63
|
worktreesMissing.push({ workspaceId: ws.id, name: ws.name, path: wtPath, exists: false });
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
-
// Orphan agent sessions — marked running but PID no longer alive
|
|
66
|
+
// Orphan agent sessions — marked running but PID no longer alive.
|
|
67
|
+
// Also collect the alive ones for the active-state panel.
|
|
67
68
|
const runningSessions = db
|
|
68
|
-
.prepare(
|
|
69
|
+
.prepare(`
|
|
70
|
+
SELECT s.pid AS pid, s.workspace_id AS workspaceId, s.started_at AS startedAt, w.name AS workspaceName
|
|
71
|
+
FROM agent_sessions s
|
|
72
|
+
JOIN workspaces w ON w.id = s.workspace_id
|
|
73
|
+
WHERE s.status = 'running' AND s.pid IS NOT NULL
|
|
74
|
+
`)
|
|
69
75
|
.all();
|
|
70
76
|
let orphaned = 0;
|
|
77
|
+
const agentSessionsAlive = [];
|
|
71
78
|
for (const s of runningSessions) {
|
|
72
|
-
if (
|
|
79
|
+
if (!s.pid)
|
|
80
|
+
continue;
|
|
81
|
+
if (isProcessAlive(s.pid)) {
|
|
82
|
+
agentSessionsAlive.push({
|
|
83
|
+
workspaceId: s.workspaceId,
|
|
84
|
+
workspaceName: s.workspaceName,
|
|
85
|
+
pid: s.pid,
|
|
86
|
+
startedAt: s.startedAt,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
73
90
|
orphaned++;
|
|
91
|
+
}
|
|
74
92
|
}
|
|
93
|
+
// Active state — features the user wants to see at a glance:
|
|
94
|
+
// pending quota backoffs, scheduled wakeups, armed auto-loops, running dev servers.
|
|
95
|
+
const quotaBackoffs = db
|
|
96
|
+
.prepare(`
|
|
97
|
+
SELECT q.workspace_id AS workspaceId, w.name AS name, q.target_at AS targetAt,
|
|
98
|
+
q.resets_at AS resetsAt, q.source AS source, q.retry_count AS retryCount
|
|
99
|
+
FROM pending_quota_backoffs q
|
|
100
|
+
JOIN workspaces w ON w.id = q.workspace_id
|
|
101
|
+
ORDER BY q.target_at ASC
|
|
102
|
+
`)
|
|
103
|
+
.all();
|
|
104
|
+
const pendingWakeups = db
|
|
105
|
+
.prepare(`
|
|
106
|
+
SELECT p.workspace_id AS workspaceId, w.name AS name, p.target_at AS targetAt, p.reason AS reason
|
|
107
|
+
FROM pending_wakeups p
|
|
108
|
+
JOIN workspaces w ON w.id = p.workspace_id
|
|
109
|
+
ORDER BY p.target_at ASC
|
|
110
|
+
`)
|
|
111
|
+
.all();
|
|
112
|
+
const autoLoopActiveRaw = db
|
|
113
|
+
.prepare(`
|
|
114
|
+
SELECT id AS workspaceId, name, auto_loop_ready AS ready
|
|
115
|
+
FROM workspaces
|
|
116
|
+
WHERE auto_loop = 1 AND archived_at IS NULL
|
|
117
|
+
ORDER BY name ASC
|
|
118
|
+
`)
|
|
119
|
+
.all();
|
|
120
|
+
const autoLoopActive = autoLoopActiveRaw.map((r) => ({
|
|
121
|
+
workspaceId: r.workspaceId,
|
|
122
|
+
name: r.name,
|
|
123
|
+
ready: r.ready === 1,
|
|
124
|
+
}));
|
|
125
|
+
const devServersRunning = db
|
|
126
|
+
.prepare(`
|
|
127
|
+
SELECT id AS workspaceId, name
|
|
128
|
+
FROM workspaces
|
|
129
|
+
WHERE dev_server_status = 'running' AND archived_at IS NULL
|
|
130
|
+
ORDER BY name ASC
|
|
131
|
+
`)
|
|
132
|
+
.all();
|
|
75
133
|
const settingsRow = db.prepare('SELECT COUNT(*) as n FROM workspaces').get();
|
|
76
134
|
const archivedRow = db.prepare('SELECT COUNT(*) as n FROM workspaces WHERE archived_at IS NOT NULL').get();
|
|
77
135
|
const report = {
|
|
@@ -95,6 +153,13 @@ app.get('/report', (c) => {
|
|
|
95
153
|
sentry: { configured: Boolean(healthGlobalSettings.sentryMcpKey) },
|
|
96
154
|
editor: { configured: Boolean(healthGlobalSettings.editorCommand) },
|
|
97
155
|
},
|
|
156
|
+
active: {
|
|
157
|
+
quotaBackoffs,
|
|
158
|
+
pendingWakeups,
|
|
159
|
+
autoLoopActive,
|
|
160
|
+
agentSessionsAlive,
|
|
161
|
+
devServersRunning,
|
|
162
|
+
},
|
|
98
163
|
};
|
|
99
164
|
return c.json(report);
|
|
100
165
|
});
|