2ndbrain 2026.1.32 → 2026.1.34
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/.claude/settings.local.json +5 -1
- package/db/migrations/002_scheduled_tasks.sql +25 -0
- package/doc/SPEC.md +79 -1
- package/package.json +9 -2
- package/skills/project-manage/SKILL.md +80 -0
- package/skills/scheduler/SKILL.md +207 -0
- package/src/config.js +13 -0
- package/src/db/pool.js +35 -1
- package/src/hooks/lifecycle.js +5 -0
- package/src/index.js +22 -1
- package/src/scheduler/worker.js +320 -0
- package/src/telegram/commands.js +17 -1
- package/src/web/server.js +161 -17
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
"WebFetch(domain:latenode.com)",
|
|
12
12
|
"Bash(claude:*)",
|
|
13
13
|
"Bash(printf:*)",
|
|
14
|
-
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\\\\migrations\"\" 2>/dev/null || echo \"No migrations directory \")"
|
|
14
|
+
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\\\\migrations\"\" 2>/dev/null || echo \"No migrations directory \")",
|
|
15
|
+
"Bash(node:*)",
|
|
16
|
+
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\src\\\\web\"\" 2>/dev/null || echo \"web directory not found \")",
|
|
17
|
+
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\"\" 2>/dev/null || echo \"Directory not accessible \")",
|
|
18
|
+
"Bash(npm install:*)"
|
|
15
19
|
]
|
|
16
20
|
}
|
|
17
21
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- 002_scheduled_tasks.sql
|
|
2
|
+
-- Scheduled tasks table for the /schedule feature (cron-based task execution)
|
|
3
|
+
|
|
4
|
+
CREATE TABLE scheduled_tasks (
|
|
5
|
+
id SERIAL PRIMARY KEY,
|
|
6
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
7
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
8
|
+
chat_id TEXT NOT NULL, -- Telegram chat ID for delivering results
|
|
9
|
+
cron_expression TEXT NOT NULL, -- Standard 5-field cron: min hour dom month dow
|
|
10
|
+
task_prompt TEXT NOT NULL, -- Prompt sent to Claude when the task fires
|
|
11
|
+
description TEXT NOT NULL DEFAULT '', -- Human-readable description
|
|
12
|
+
timezone TEXT NOT NULL DEFAULT 'UTC', -- IANA timezone (e.g. 'America/New_York')
|
|
13
|
+
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
14
|
+
last_run_at TIMESTAMPTZ,
|
|
15
|
+
next_run_at TIMESTAMPTZ,
|
|
16
|
+
error_count INTEGER NOT NULL DEFAULT 0, -- Consecutive error count for backoff
|
|
17
|
+
last_error TEXT -- Last error message, if any
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX idx_scheduled_tasks_next_run
|
|
21
|
+
ON scheduled_tasks (next_run_at)
|
|
22
|
+
WHERE enabled = TRUE;
|
|
23
|
+
|
|
24
|
+
CREATE INDEX idx_scheduled_tasks_chat_id
|
|
25
|
+
ON scheduled_tasks (chat_id);
|
package/doc/SPEC.md
CHANGED
|
@@ -103,6 +103,7 @@ See also: §4 (Command Router), §8 (Attachment Store), §14 (Security Model),
|
|
|
103
103
|
|
|
104
104
|
Parses incoming messages and routes them:
|
|
105
105
|
- Messages prefixed with `/` are dispatched to slash command handlers
|
|
106
|
+
- Pass-through commands are forwarded to the Claude Bridge where the corresponding skill handles them (§12)
|
|
106
107
|
- All other messages are forwarded to the Claude Bridge (§5)
|
|
107
108
|
|
|
108
109
|
### Slash Commands
|
|
@@ -115,11 +116,17 @@ Parses incoming messages and routes them:
|
|
|
115
116
|
| `/restart` | none | Process restart | Confirmation prompt; on confirm, restart process |
|
|
116
117
|
| `/reboot` | none | OS reboot | Confirmation prompt; on confirm, `sudo reboot` |
|
|
117
118
|
| `/new` | none | Start new conversation session | Confirmation; old session preserved in logs |
|
|
119
|
+
| `/project` | optional description | Manage projects, specs, issues | Pass-through to Claude (§12, `project-manage` skill) |
|
|
120
|
+
| `/journal` | optional text | Record or search journal entries | Pass-through to Claude (§12, `journal` skill) |
|
|
121
|
+
| `/knowledge` | optional text | Manage knowledge graph | Pass-through to Claude (§12, `knowledge` skill) |
|
|
122
|
+
| `/schedule` | optional description | Create, list, or manage scheduled tasks | Pass-through to Claude (§12, `scheduler` skill) |
|
|
118
123
|
| `/help` | none | List available commands | Command list with descriptions |
|
|
119
124
|
|
|
120
125
|
`/restart` and `/reboot` require explicit confirmation reply before execution (see §14.4). `/new` starts a fresh Claude session; the previous session remains accessible in logs and search.
|
|
121
126
|
|
|
122
|
-
|
|
127
|
+
**Pass-through commands** (`/project`, `/journal`, `/knowledge`, `/schedule`) are recognized by the command router but not handled locally. Instead, the full message text is forwarded to the Claude Bridge as a conversational message. The corresponding Claude skill (§12) activates on the `/command` prefix and performs the database operations. Pass-through commands go through the normal message flow including rate limiting, conversation persistence, and lifecycle hooks.
|
|
128
|
+
|
|
129
|
+
See also: §5 (Claude Bridge), §12 (Claude Skills), §14.4 (Dangerous Operations)
|
|
123
130
|
|
|
124
131
|
|
|
125
132
|
## 5. Claude Bridge
|
|
@@ -315,6 +322,31 @@ Structured operational logs are written to the `system_logs` table with level (`
|
|
|
315
322
|
|
|
316
323
|
See also: §9 (Web Admin Server), §15 (Error Handling), §17.1 (`system_logs` table), §18 (rate limit and log variables)
|
|
317
324
|
|
|
325
|
+
### 10.2 Scheduler Worker
|
|
326
|
+
|
|
327
|
+
Background service that executes scheduled tasks defined in the `scheduled_tasks` table (§17.7).
|
|
328
|
+
|
|
329
|
+
| Property | Value |
|
|
330
|
+
|----------|-------|
|
|
331
|
+
| Poll interval | 60 seconds |
|
|
332
|
+
| Max tasks per tick | 3 |
|
|
333
|
+
| Auto-disable threshold | 5 consecutive errors |
|
|
334
|
+
| Pattern | `EmbeddingWorker` (setTimeout-based polling loop) |
|
|
335
|
+
|
|
336
|
+
**Execution flow:**
|
|
337
|
+
1. Poll `scheduled_tasks` for rows where `enabled = TRUE AND next_run_at <= NOW()`
|
|
338
|
+
2. Check `ClaudeBridge.isActive()` -- skip if a user message is being processed
|
|
339
|
+
3. Acquire Claude rate limiter slot
|
|
340
|
+
4. Invoke `claude-cli` with the task's `task_prompt` (fresh session per execution)
|
|
341
|
+
5. Deliver the response to the user's Telegram chat, prefixed with `[Scheduled: <description>]`
|
|
342
|
+
6. Compute `next_run_at` using `node-cron` and update the row
|
|
343
|
+
|
|
344
|
+
**Startup recovery:** On service start, recomputes `next_run_at` for all enabled tasks with NULL or past values. Tasks missed during downtime skip ahead to the next scheduled time (no retroactive execution).
|
|
345
|
+
|
|
346
|
+
**Error handling:** Consecutive failures increment `error_count`. After 5 failures, the task is auto-disabled and the user is notified via Telegram. Re-enabling via `/schedule` resets the error count.
|
|
347
|
+
|
|
348
|
+
See also: §12.2 (`scheduler` skill), §17.7 (`scheduled_tasks` table)
|
|
349
|
+
|
|
318
350
|
|
|
319
351
|
## 11. Knowledge Platform
|
|
320
352
|
|
|
@@ -403,6 +435,7 @@ $DATA_DIR/claude-runtime/.claude/skills/
|
|
|
403
435
|
knowledge/SKILL.md
|
|
404
436
|
project-manage/SKILL.md
|
|
405
437
|
recall/SKILL.md
|
|
438
|
+
scheduler/SKILL.md
|
|
406
439
|
system-ops/SKILL.md
|
|
407
440
|
```
|
|
408
441
|
|
|
@@ -507,6 +540,26 @@ Each skill is defined as a `SKILL.md` file containing natural-language instructi
|
|
|
507
540
|
|
|
508
541
|
**Restrictions:** This skill provides read-only system access only. It does NOT support restart, reboot, shutdown, or any mutating operations. Those remain exclusive to slash commands with confirmation prompts (§4).
|
|
509
542
|
|
|
543
|
+
#### `scheduler` -- Scheduled Task Management
|
|
544
|
+
|
|
545
|
+
| Property | Value |
|
|
546
|
+
|----------|-------|
|
|
547
|
+
| Name | `scheduler` |
|
|
548
|
+
| Description | Create, list, update, and delete scheduled tasks. Parse natural language schedules into cron expressions and persist them for automatic execution. |
|
|
549
|
+
| Invocation | User sends message with `/schedule` preamble, or automatic (Claude detects scheduling intent: "every morning remind me to...", "schedule a daily check on...") |
|
|
550
|
+
| Allowed tools | PostgreSQL MCP (`mcp__pg__query`) |
|
|
551
|
+
| Tables | `scheduled_tasks` |
|
|
552
|
+
|
|
553
|
+
**Behaviors:**
|
|
554
|
+
- **Create task:** Parse natural language into a 5-field cron expression and task prompt. `INSERT INTO scheduled_tasks (chat_id, cron_expression, task_prompt, description, timezone) VALUES ($1, $2, $3, $4, $5)`. The `chat_id` is obtained from the system prompt context.
|
|
555
|
+
- **List tasks:** `SELECT * FROM scheduled_tasks WHERE chat_id = $1 ORDER BY created_at DESC`
|
|
556
|
+
- **Update task:** `UPDATE scheduled_tasks SET cron_expression = $1, task_prompt = $2, description = $3, next_run_at = NULL, updated_at = NOW() WHERE id = $4 AND chat_id = $5`. Setting `next_run_at = NULL` forces the scheduler worker to recompute it.
|
|
557
|
+
- **Enable/disable:** `UPDATE scheduled_tasks SET enabled = $1, error_count = 0, updated_at = NOW() WHERE id = $2 AND chat_id = $3`
|
|
558
|
+
- **Delete task:** `DELETE FROM scheduled_tasks WHERE id = $1 AND chat_id = $2`
|
|
559
|
+
- **Search tasks:** `SELECT * FROM scheduled_tasks WHERE chat_id = $1 AND (description ILIKE $2 OR task_prompt ILIKE $2)`
|
|
560
|
+
|
|
561
|
+
**Execution model:** The skill only manages task definitions in the database. Actual execution is performed by the `SchedulerWorker` background service (§10.2), which polls the `scheduled_tasks` table every 60 seconds and invokes `claude-cli` for tasks whose `next_run_at` has passed. After 5 consecutive execution failures, a task is auto-disabled and the user is notified.
|
|
562
|
+
|
|
510
563
|
|
|
511
564
|
## 13. Claude Hooks
|
|
512
565
|
|
|
@@ -864,6 +917,31 @@ CREATE INDEX idx_embeddings_vector
|
|
|
864
917
|
ON embeddings USING hnsw (vector vector_cosine_ops);
|
|
865
918
|
```
|
|
866
919
|
|
|
920
|
+
### 17.7 Scheduled Tasks
|
|
921
|
+
|
|
922
|
+
```sql
|
|
923
|
+
CREATE TABLE scheduled_tasks (
|
|
924
|
+
id SERIAL PRIMARY KEY,
|
|
925
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
926
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
927
|
+
chat_id TEXT NOT NULL, -- Telegram chat ID for delivering results
|
|
928
|
+
cron_expression TEXT NOT NULL, -- Standard 5-field cron: min hour dom month dow
|
|
929
|
+
task_prompt TEXT NOT NULL, -- Prompt sent to Claude when the task fires
|
|
930
|
+
description TEXT NOT NULL DEFAULT '', -- Human-readable description
|
|
931
|
+
timezone TEXT NOT NULL DEFAULT 'UTC', -- IANA timezone (e.g. 'America/New_York')
|
|
932
|
+
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
933
|
+
last_run_at TIMESTAMPTZ,
|
|
934
|
+
next_run_at TIMESTAMPTZ, -- Pre-computed by SchedulerWorker
|
|
935
|
+
error_count INTEGER NOT NULL DEFAULT 0, -- Consecutive error count for backoff
|
|
936
|
+
last_error TEXT -- Last error message, if any
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
CREATE INDEX idx_scheduled_tasks_next_run
|
|
940
|
+
ON scheduled_tasks (next_run_at) WHERE enabled = TRUE;
|
|
941
|
+
CREATE INDEX idx_scheduled_tasks_chat_id
|
|
942
|
+
ON scheduled_tasks (chat_id);
|
|
943
|
+
```
|
|
944
|
+
|
|
867
945
|
|
|
868
946
|
## 18. Configuration Reference
|
|
869
947
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "2ndbrain",
|
|
3
|
-
"version": "2026.1.
|
|
3
|
+
"version": "2026.1.34",
|
|
4
4
|
"description": "Always-on Node.js service bridging Telegram messaging to Claude AI with knowledge graph, journal, project management, and semantic search.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
"type": "git",
|
|
16
16
|
"url": "git+https://github.com/fingerskier/2ndbrain.git"
|
|
17
17
|
},
|
|
18
|
-
"keywords": [
|
|
18
|
+
"keywords": [
|
|
19
|
+
"telegram",
|
|
20
|
+
"claude",
|
|
21
|
+
"ai",
|
|
22
|
+
"assistant",
|
|
23
|
+
"knowledge-graph"
|
|
24
|
+
],
|
|
19
25
|
"author": "",
|
|
20
26
|
"license": "MIT",
|
|
21
27
|
"bugs": {
|
|
@@ -25,6 +31,7 @@
|
|
|
25
31
|
"dependencies": {
|
|
26
32
|
"dotenv": "^16.4.7",
|
|
27
33
|
"express": "^4.21.2",
|
|
34
|
+
"node-cron": "^4.2.1",
|
|
28
35
|
"open": "^10.1.0",
|
|
29
36
|
"pg": "^8.13.1"
|
|
30
37
|
},
|
|
@@ -57,6 +57,86 @@ No other tools are permitted for this skill.
|
|
|
57
57
|
|
|
58
58
|
## Operations
|
|
59
59
|
|
|
60
|
+
### List All Projects (No Arguments)
|
|
61
|
+
|
|
62
|
+
When the user sends `/project` with no additional text, list all projects with status:
|
|
63
|
+
|
|
64
|
+
```sql
|
|
65
|
+
SELECT
|
|
66
|
+
p.id,
|
|
67
|
+
p.name,
|
|
68
|
+
p.created_at,
|
|
69
|
+
COUNT(i.id) FILTER (WHERE NOT i.completed) AS open_issues,
|
|
70
|
+
COUNT(i.id) FILTER (WHERE i.completed) AS closed_issues,
|
|
71
|
+
COUNT(DISTINCT s.id) AS spec_count
|
|
72
|
+
FROM projects p
|
|
73
|
+
LEFT JOIN issues i ON i.project_id = p.id
|
|
74
|
+
LEFT JOIN specifications s ON s.project_id = p.id
|
|
75
|
+
GROUP BY p.id, p.name, p.created_at
|
|
76
|
+
ORDER BY p.updated_at DESC;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Present as a concise listing. If no projects exist, respond: "No projects found. Send `/project <description>` to create one."
|
|
80
|
+
|
|
81
|
+
### Parse and Scaffold from Description
|
|
82
|
+
|
|
83
|
+
When the user sends `/project` followed by a natural language description, parse it and create the appropriate database records:
|
|
84
|
+
|
|
85
|
+
1. **Extract the project name** from the description. Use the most prominent noun phrase or explicit project name.
|
|
86
|
+
|
|
87
|
+
2. **Check for existing project** (case-insensitive):
|
|
88
|
+
|
|
89
|
+
```sql
|
|
90
|
+
SELECT id, name FROM projects WHERE name ILIKE '%project name%';
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- If a match exists, use the existing project ID (do not create a duplicate).
|
|
94
|
+
- If multiple matches, list them and ask the user to clarify.
|
|
95
|
+
- If no match, create a new project.
|
|
96
|
+
|
|
97
|
+
3. **Extract specifications** -- any requirements, constraints, architecture decisions, API contracts, or design notes from the description. Insert each:
|
|
98
|
+
|
|
99
|
+
```sql
|
|
100
|
+
INSERT INTO specifications (project_id, note)
|
|
101
|
+
VALUES (<project_id>, 'extracted specification')
|
|
102
|
+
RETURNING id, note;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use `parent_id` when specifications have a natural parent-child hierarchy.
|
|
106
|
+
|
|
107
|
+
4. **Extract issues/tasks** -- any action items, features to build, bugs to fix, or work items. Insert each:
|
|
108
|
+
|
|
109
|
+
```sql
|
|
110
|
+
INSERT INTO issues (project_id, note)
|
|
111
|
+
VALUES (<project_id>, 'extracted task or issue')
|
|
112
|
+
RETURNING id, note;
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Use `parent_id` when tasks have a natural hierarchy.
|
|
116
|
+
|
|
117
|
+
5. **Upsert behavior:** Before inserting a specification or issue, check if one with very similar text already exists for this project:
|
|
118
|
+
|
|
119
|
+
```sql
|
|
120
|
+
SELECT id, note FROM specifications WHERE project_id = <project_id> AND note ILIKE '%key phrase%';
|
|
121
|
+
SELECT id, note FROM issues WHERE project_id = <project_id> AND note ILIKE '%key phrase%';
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
If a match exists, skip it (note as "already exists" in the summary). Do not delete existing records.
|
|
125
|
+
|
|
126
|
+
6. **Respond with a summary** of everything created or found:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
Project: <name> (id: <id>) [created | existing]
|
|
130
|
+
|
|
131
|
+
Specifications:
|
|
132
|
+
- <spec note> (id: <id>)
|
|
133
|
+
- <spec note> (id: <id>) [already exists]
|
|
134
|
+
|
|
135
|
+
Issues:
|
|
136
|
+
- <issue note> (id: <id>)
|
|
137
|
+
- <issue note> (id: <id>) [already exists]
|
|
138
|
+
```
|
|
139
|
+
|
|
60
140
|
### Create a Project
|
|
61
141
|
|
|
62
142
|
When the user wants to start tracking a new project.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Skill: scheduler
|
|
2
|
+
|
|
3
|
+
## Description
|
|
4
|
+
|
|
5
|
+
Create, list, update, and delete scheduled tasks. Use this skill when the user wants to set up recurring actions that run on a cron schedule. The user describes the task and schedule in natural language; you parse it into a cron expression and a task prompt, then persist it to the database. A background worker executes each task at its scheduled time by sending the prompt to Claude and delivering the response to the user's Telegram chat.
|
|
6
|
+
|
|
7
|
+
## When to Activate
|
|
8
|
+
|
|
9
|
+
Activate this skill when any of the following conditions are met:
|
|
10
|
+
|
|
11
|
+
- The user's message begins with `/schedule`
|
|
12
|
+
- The user wants to set up a recurring task (e.g., "every morning remind me to...", "schedule a daily check on...", "run this every Monday")
|
|
13
|
+
- The user asks about their scheduled tasks (e.g., "what's scheduled", "list my schedules", "show my cron jobs")
|
|
14
|
+
- The user wants to modify or cancel a scheduled task (e.g., "stop the daily reminder", "change the schedule to weekly", "disable schedule #3")
|
|
15
|
+
|
|
16
|
+
## Available Tools
|
|
17
|
+
|
|
18
|
+
- `mcp__pg__query` -- Execute SQL queries against the PostgreSQL database.
|
|
19
|
+
|
|
20
|
+
No other tools are permitted for this skill.
|
|
21
|
+
|
|
22
|
+
## Context
|
|
23
|
+
|
|
24
|
+
The current Telegram `chat_id` is provided in the system prompt as `Current Telegram chat_id: <value>`. You MUST use this value when inserting or querying scheduled tasks to scope operations to the current user's chat.
|
|
25
|
+
|
|
26
|
+
## Database Tables
|
|
27
|
+
|
|
28
|
+
### `scheduled_tasks`
|
|
29
|
+
|
|
30
|
+
| Column | Type | Description |
|
|
31
|
+
|--------|------|-------------|
|
|
32
|
+
| `id` | SERIAL PRIMARY KEY | Auto-incrementing identifier |
|
|
33
|
+
| `created_at` | TIMESTAMPTZ | Timestamp of creation |
|
|
34
|
+
| `updated_at` | TIMESTAMPTZ | Timestamp of last update |
|
|
35
|
+
| `chat_id` | TEXT NOT NULL | Telegram chat ID (from system prompt) |
|
|
36
|
+
| `cron_expression` | TEXT NOT NULL | Standard 5-field cron: minute hour day-of-month month day-of-week |
|
|
37
|
+
| `task_prompt` | TEXT NOT NULL | The prompt sent to Claude when the task fires |
|
|
38
|
+
| `description` | TEXT NOT NULL DEFAULT '' | Human-readable description of the task |
|
|
39
|
+
| `timezone` | TEXT NOT NULL DEFAULT 'UTC' | IANA timezone for cron evaluation |
|
|
40
|
+
| `enabled` | BOOLEAN NOT NULL DEFAULT TRUE | Whether the task is active |
|
|
41
|
+
| `last_run_at` | TIMESTAMPTZ | When the task last executed |
|
|
42
|
+
| `next_run_at` | TIMESTAMPTZ | Pre-computed next execution time |
|
|
43
|
+
| `error_count` | INTEGER NOT NULL DEFAULT 0 | Consecutive error count |
|
|
44
|
+
| `last_error` | TEXT | Most recent error message |
|
|
45
|
+
|
|
46
|
+
## Cron Expression Reference
|
|
47
|
+
|
|
48
|
+
Standard 5-field cron format: `minute hour day-of-month month day-of-week`
|
|
49
|
+
|
|
50
|
+
| Field | Values | Special Characters |
|
|
51
|
+
|-------|--------|--------------------|
|
|
52
|
+
| Minute | 0-59 | `*` `,` `-` `/` |
|
|
53
|
+
| Hour | 0-23 | `*` `,` `-` `/` |
|
|
54
|
+
| Day of Month | 1-31 | `*` `,` `-` `/` |
|
|
55
|
+
| Month | 1-12 | `*` `,` `-` `/` |
|
|
56
|
+
| Day of Week | 0-6 (0=Sunday) | `*` `,` `-` `/` |
|
|
57
|
+
|
|
58
|
+
### Common Patterns
|
|
59
|
+
|
|
60
|
+
| Natural Language | Cron Expression |
|
|
61
|
+
|-----------------|-----------------|
|
|
62
|
+
| every day at 8am | `0 8 * * *` |
|
|
63
|
+
| every weekday at 9am | `0 9 * * 1-5` |
|
|
64
|
+
| every Monday at 10am | `0 10 * * 1` |
|
|
65
|
+
| every hour | `0 * * * *` |
|
|
66
|
+
| every 30 minutes | `*/30 * * * *` |
|
|
67
|
+
| every 15 minutes | `*/15 * * * *` |
|
|
68
|
+
| first of every month at noon | `0 12 1 * *` |
|
|
69
|
+
| every Sunday at 6pm | `0 18 * * 0` |
|
|
70
|
+
| twice a day (8am and 8pm) | `0 8,20 * * *` |
|
|
71
|
+
| every weekday at 5pm | `0 17 * * 1-5` |
|
|
72
|
+
| every 6 hours | `0 */6 * * *` |
|
|
73
|
+
|
|
74
|
+
## Operations
|
|
75
|
+
|
|
76
|
+
### List All Scheduled Tasks (No Arguments)
|
|
77
|
+
|
|
78
|
+
When the user sends `/schedule` with no additional text, list all their tasks:
|
|
79
|
+
|
|
80
|
+
```sql
|
|
81
|
+
SELECT id, description, cron_expression, timezone, enabled,
|
|
82
|
+
last_run_at, next_run_at, error_count, last_error
|
|
83
|
+
FROM scheduled_tasks
|
|
84
|
+
WHERE chat_id = '<chat_id>'
|
|
85
|
+
ORDER BY created_at DESC;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Present as a concise listing showing: ID, description, schedule (human-readable interpretation of the cron), enabled status, and next run time. If no tasks exist, respond: "No scheduled tasks found. Send `/schedule <description>` to create one."
|
|
89
|
+
|
|
90
|
+
Example format:
|
|
91
|
+
```
|
|
92
|
+
Your Scheduled Tasks:
|
|
93
|
+
|
|
94
|
+
#1 - Check project status
|
|
95
|
+
Schedule: Every day at 8:00 AM (UTC)
|
|
96
|
+
Status: Enabled
|
|
97
|
+
Next run: 2026-02-01 08:00 UTC
|
|
98
|
+
|
|
99
|
+
#2 - Weekly team summary
|
|
100
|
+
Schedule: Every Monday at 9:00 AM (America/New_York)
|
|
101
|
+
Status: Disabled (5 errors)
|
|
102
|
+
Last error: Claude subprocess timed out
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Create a Scheduled Task
|
|
106
|
+
|
|
107
|
+
When the user sends `/schedule` followed by a natural language description, parse it:
|
|
108
|
+
|
|
109
|
+
1. **Extract the schedule** from the natural language and convert to a 5-field cron expression.
|
|
110
|
+
2. **Extract the task prompt** -- this is what Claude will be asked to do when the task fires. Formulate it as a clear, actionable prompt. For example, if the user says "remind me to check my project status", the task prompt should be something like: "Check my project status and give me a summary of open issues across all projects."
|
|
111
|
+
3. **Extract or infer the timezone** -- if the user mentions a timezone, use it. Otherwise default to UTC. If unclear, ask.
|
|
112
|
+
4. **Write a human-readable description** summarizing the task.
|
|
113
|
+
|
|
114
|
+
```sql
|
|
115
|
+
INSERT INTO scheduled_tasks (chat_id, cron_expression, task_prompt, description, timezone)
|
|
116
|
+
VALUES ('<chat_id>', '<cron>', '<task_prompt>', '<description>', '<timezone>')
|
|
117
|
+
RETURNING id, cron_expression, task_prompt, description, timezone;
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
After creating, confirm with the user:
|
|
121
|
+
```
|
|
122
|
+
Scheduled task created:
|
|
123
|
+
#<id> - <description>
|
|
124
|
+
Schedule: <human-readable cron interpretation> (<timezone>)
|
|
125
|
+
Prompt: "<task_prompt>"
|
|
126
|
+
|
|
127
|
+
The task will first run at <next computed time>. The background scheduler picks up new tasks within 60 seconds.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Important:** Do NOT set `next_run_at` yourself. The background scheduler worker computes and sets `next_run_at` automatically when it detects new tasks with a NULL `next_run_at`.
|
|
131
|
+
|
|
132
|
+
### Update a Scheduled Task
|
|
133
|
+
|
|
134
|
+
When the user wants to change the schedule, prompt, or description of an existing task.
|
|
135
|
+
|
|
136
|
+
First, verify the task belongs to this chat:
|
|
137
|
+
|
|
138
|
+
```sql
|
|
139
|
+
SELECT id, description, cron_expression, task_prompt, timezone
|
|
140
|
+
FROM scheduled_tasks
|
|
141
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then update the requested fields:
|
|
145
|
+
|
|
146
|
+
```sql
|
|
147
|
+
UPDATE scheduled_tasks
|
|
148
|
+
SET cron_expression = '<new_cron>',
|
|
149
|
+
task_prompt = '<new_prompt>',
|
|
150
|
+
description = '<new_description>',
|
|
151
|
+
timezone = '<new_timezone>',
|
|
152
|
+
next_run_at = NULL,
|
|
153
|
+
updated_at = NOW()
|
|
154
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Setting `next_run_at = NULL` forces the scheduler worker to recompute it on the next poll.
|
|
158
|
+
|
|
159
|
+
### Enable / Disable a Task
|
|
160
|
+
|
|
161
|
+
```sql
|
|
162
|
+
UPDATE scheduled_tasks
|
|
163
|
+
SET enabled = TRUE, error_count = 0, last_error = NULL,
|
|
164
|
+
next_run_at = NULL, updated_at = NOW()
|
|
165
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```sql
|
|
169
|
+
UPDATE scheduled_tasks
|
|
170
|
+
SET enabled = FALSE, updated_at = NOW()
|
|
171
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
When re-enabling, reset `error_count` and `last_error`, and set `next_run_at = NULL` so the scheduler recomputes it.
|
|
175
|
+
|
|
176
|
+
### Delete a Scheduled Task
|
|
177
|
+
|
|
178
|
+
When the user wants to permanently remove a task.
|
|
179
|
+
|
|
180
|
+
```sql
|
|
181
|
+
DELETE FROM scheduled_tasks
|
|
182
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Confirm deletion to the user. If the task ID does not exist or does not belong to this chat, say so.
|
|
186
|
+
|
|
187
|
+
### Search Scheduled Tasks
|
|
188
|
+
|
|
189
|
+
```sql
|
|
190
|
+
SELECT id, description, cron_expression, timezone, enabled
|
|
191
|
+
FROM scheduled_tasks
|
|
192
|
+
WHERE chat_id = '<chat_id>'
|
|
193
|
+
AND (description ILIKE '%' || '<search_term>' || '%'
|
|
194
|
+
OR task_prompt ILIKE '%' || '<search_term>' || '%')
|
|
195
|
+
ORDER BY created_at DESC;
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Restrictions and Notes
|
|
199
|
+
|
|
200
|
+
- Always use `mcp__pg__query` for database operations. Do not use any other tool.
|
|
201
|
+
- Always scope queries with `chat_id = '<chat_id>'` using the chat ID from the system prompt. Never access another chat's tasks.
|
|
202
|
+
- When the user refers to a task by number (e.g., "disable #3" or "delete task 3"), use that as the `id`.
|
|
203
|
+
- If the user's schedule description is ambiguous (e.g., "every morning" -- what time exactly?), ask for clarification rather than guessing.
|
|
204
|
+
- The cron expression must be valid 5-field format. Do not use 6-field (with seconds) or non-standard extensions.
|
|
205
|
+
- When listing tasks, always translate the cron expression into human-readable language (e.g., `0 8 * * 1-5` = "Every weekday at 8:00 AM").
|
|
206
|
+
- Task prompts should be self-contained and actionable -- the Claude instance executing them will not have the original conversation context. Write prompts that make sense in isolation.
|
|
207
|
+
- Do not set or modify `next_run_at`, `last_run_at`, `error_count`, or `last_error` when creating or updating tasks. These are managed by the scheduler worker.
|
package/src/config.js
CHANGED
|
@@ -60,6 +60,19 @@ const config = {
|
|
|
60
60
|
EMBEDDING_BASE_URL: env.EMBEDDING_BASE_URL || '',
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// Ensure DATABASE_URL includes a database name (default: 2ndbrain)
|
|
64
|
+
if (config.DATABASE_URL) {
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(config.DATABASE_URL);
|
|
67
|
+
if (!url.pathname || url.pathname === '/') {
|
|
68
|
+
url.pathname = '/2ndbrain';
|
|
69
|
+
config.DATABASE_URL = url.toString();
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// leave as-is if URL parsing fails
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
const REQUIRED_VARS = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_ALLOWED_USERS', 'DATABASE_URL'];
|
|
64
77
|
|
|
65
78
|
/**
|
package/src/db/pool.js
CHANGED
|
@@ -12,6 +12,40 @@ pool.on('error', (err) => {
|
|
|
12
12
|
console.error(`[${new Date().toISOString()}] [error] [db/pool] Unexpected idle client error:`, err.message);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Ensure the target database exists, creating it if necessary.
|
|
17
|
+
* Connects to the 'postgres' maintenance database to check/create.
|
|
18
|
+
*/
|
|
19
|
+
async function ensureDatabase() {
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(config.DATABASE_URL);
|
|
22
|
+
const dbName = url.pathname.slice(1); // strip leading '/'
|
|
23
|
+
if (!dbName) return;
|
|
24
|
+
|
|
25
|
+
// Build a maintenance URL pointing at the 'postgres' system database
|
|
26
|
+
const maintenanceUrl = new URL(config.DATABASE_URL);
|
|
27
|
+
maintenanceUrl.pathname = '/postgres';
|
|
28
|
+
|
|
29
|
+
const client = new pg.Client({ connectionString: maintenanceUrl.toString() });
|
|
30
|
+
await client.connect();
|
|
31
|
+
|
|
32
|
+
const result = await client.query(
|
|
33
|
+
'SELECT 1 FROM pg_database WHERE datname = $1',
|
|
34
|
+
[dbName],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (result.rows.length === 0) {
|
|
38
|
+
// Use double-quoted identifier to handle special characters in name
|
|
39
|
+
await client.query(`CREATE DATABASE "${dbName}"`);
|
|
40
|
+
console.log(`[${new Date().toISOString()}] [info] [db/pool] Created database "${dbName}".`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await client.end();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`[${new Date().toISOString()}] [error] [db/pool] ensureDatabase failed: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
15
49
|
/**
|
|
16
50
|
* Execute a parameterized SQL query against the pool.
|
|
17
51
|
* @param {string} text - SQL query string
|
|
@@ -29,5 +63,5 @@ async function close() {
|
|
|
29
63
|
await pool.end();
|
|
30
64
|
}
|
|
31
65
|
|
|
32
|
-
export { pool, query, close };
|
|
66
|
+
export { pool, query, close, ensureDatabase };
|
|
33
67
|
export default pool;
|
package/src/hooks/lifecycle.js
CHANGED
|
@@ -239,6 +239,11 @@ class LifecycleHooks extends EventEmitter {
|
|
|
239
239
|
ctx.systemPrompt = dateContext;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Inject chat_id for skills that need it (e.g., scheduler)
|
|
243
|
+
if (ctx.chatId) {
|
|
244
|
+
ctx.systemPrompt += `\nCurrent Telegram chat_id: ${ctx.chatId}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
242
247
|
// Assemble conversation context from recent history if db is available
|
|
243
248
|
if (db && ctx.includeHistory !== false) {
|
|
244
249
|
try {
|
package/src/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
|
|
22
22
|
import { config, validateConfig, isFirstRun, PROJECT_ROOT } from './config.js';
|
|
23
|
-
import { pool, query, close as closeDb } from './db/pool.js';
|
|
23
|
+
import { pool, query, close as closeDb, ensureDatabase } from './db/pool.js';
|
|
24
24
|
import { migrate } from './db/migrate.js';
|
|
25
25
|
import logger from './logging.js';
|
|
26
26
|
import { createRateLimiters } from './rate-limiter.js';
|
|
@@ -34,6 +34,7 @@ import { createEmbedTool } from './mcp/embed-server.js';
|
|
|
34
34
|
import { AttachmentStore } from './attachments/store.js';
|
|
35
35
|
import { EmbeddingsEngine } from './embeddings/engine.js';
|
|
36
36
|
import { EmbeddingWorker } from './embeddings/worker.js';
|
|
37
|
+
import { SchedulerWorker } from './scheduler/worker.js';
|
|
37
38
|
import { WebServer } from './web/server.js';
|
|
38
39
|
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
@@ -44,6 +45,7 @@ const startTime = Date.now();
|
|
|
44
45
|
let bot = null;
|
|
45
46
|
let webServer = null;
|
|
46
47
|
let embeddingWorker = null;
|
|
48
|
+
let schedulerWorker = null;
|
|
47
49
|
let embedTool = null;
|
|
48
50
|
let shuttingDown = false;
|
|
49
51
|
|
|
@@ -178,6 +180,7 @@ async function handleMessage(message, deps) {
|
|
|
178
180
|
message: text,
|
|
179
181
|
systemPrompt: '',
|
|
180
182
|
sessionId: conversationManager.currentSessionId,
|
|
183
|
+
chatId,
|
|
181
184
|
});
|
|
182
185
|
|
|
183
186
|
if (preResult.aborted) {
|
|
@@ -296,6 +299,10 @@ async function shutdown(signal) {
|
|
|
296
299
|
try { embeddingWorker.stop(); } catch { /* ignore */ }
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
if (schedulerWorker) {
|
|
303
|
+
try { schedulerWorker.stop(); } catch { /* ignore */ }
|
|
304
|
+
}
|
|
305
|
+
|
|
299
306
|
if (embedTool?.server) {
|
|
300
307
|
try { embedTool.server.close(); } catch { /* ignore */ }
|
|
301
308
|
}
|
|
@@ -329,6 +336,7 @@ async function main() {
|
|
|
329
336
|
let dbReady = false;
|
|
330
337
|
if (config.DATABASE_URL) {
|
|
331
338
|
try {
|
|
339
|
+
await ensureDatabase();
|
|
332
340
|
await pool.query('SELECT 1');
|
|
333
341
|
logger.info('startup', 'Database connection established.');
|
|
334
342
|
dbReady = true;
|
|
@@ -515,6 +523,19 @@ async function main() {
|
|
|
515
523
|
embeddingWorker.start();
|
|
516
524
|
}
|
|
517
525
|
|
|
526
|
+
// Start background scheduler worker
|
|
527
|
+
if (dbReady && bot) {
|
|
528
|
+
schedulerWorker = new SchedulerWorker({
|
|
529
|
+
db: { query },
|
|
530
|
+
config,
|
|
531
|
+
logger,
|
|
532
|
+
claudeBridge,
|
|
533
|
+
bot,
|
|
534
|
+
rateLimiters,
|
|
535
|
+
});
|
|
536
|
+
schedulerWorker.start();
|
|
537
|
+
}
|
|
538
|
+
|
|
518
539
|
// Step 8: Set signal handlers
|
|
519
540
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
520
541
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import cron from 'node-cron';
|
|
2
|
+
|
|
3
|
+
/** Milliseconds between processing iterations (1 minute). */
|
|
4
|
+
const POLL_INTERVAL_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
/** Maximum tasks to execute per poll cycle. */
|
|
7
|
+
const MAX_TASKS_PER_TICK = 3;
|
|
8
|
+
|
|
9
|
+
/** Consecutive errors before auto-disabling a task. */
|
|
10
|
+
const MAX_CONSECUTIVE_ERRORS = 5;
|
|
11
|
+
|
|
12
|
+
/** Maximum minutes to scan forward when computing next run (366 days). */
|
|
13
|
+
const MAX_SCAN_MINUTES = 366 * 24 * 60;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute the next Date after `from` that matches the cron expression.
|
|
17
|
+
*
|
|
18
|
+
* Uses node-cron's internal timeMatcher to check each minute. Scans up
|
|
19
|
+
* to ~366 days forward before giving up.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} cronExpression - 5-field cron expression
|
|
22
|
+
* @param {Date} from - Start searching from this date (exclusive)
|
|
23
|
+
* @param {string} [timezone='UTC'] - IANA timezone for evaluation
|
|
24
|
+
* @returns {Date|null} Next matching date, or null if none found
|
|
25
|
+
*/
|
|
26
|
+
function nextCronDate(cronExpression, from, timezone = 'UTC') {
|
|
27
|
+
// Create a temporary task just to access the timeMatcher
|
|
28
|
+
const task = cron.schedule(cronExpression, () => {}, {
|
|
29
|
+
scheduled: false,
|
|
30
|
+
timezone,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const matcher = task.timeMatcher;
|
|
34
|
+
|
|
35
|
+
// Start from the next whole minute after `from`
|
|
36
|
+
const candidate = new Date(from.getTime());
|
|
37
|
+
candidate.setSeconds(0, 0);
|
|
38
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < MAX_SCAN_MINUTES; i++) {
|
|
41
|
+
if (matcher.match(candidate)) {
|
|
42
|
+
return new Date(candidate.getTime());
|
|
43
|
+
}
|
|
44
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Background scheduler worker -- periodically checks for scheduled tasks
|
|
52
|
+
* whose next_run_at has passed, executes them via the Claude bridge,
|
|
53
|
+
* and delivers results to the user's Telegram chat.
|
|
54
|
+
*
|
|
55
|
+
* Follows the EmbeddingWorker pattern (§11.4): setTimeout-based polling
|
|
56
|
+
* loop with overlap guard.
|
|
57
|
+
*/
|
|
58
|
+
class SchedulerWorker {
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} deps
|
|
61
|
+
* @param {object} deps.db - Database query interface ({ query(sql, params) })
|
|
62
|
+
* @param {object} deps.config - Application configuration
|
|
63
|
+
* @param {object} deps.logger - Logger instance
|
|
64
|
+
* @param {import('../claude/bridge.js').ClaudeBridge} deps.claudeBridge - Claude CLI bridge
|
|
65
|
+
* @param {import('../telegram/bot.js').TelegramBot} deps.bot - Telegram bot instance
|
|
66
|
+
* @param {object} deps.rateLimiters - { claude: RateLimiter, ... }
|
|
67
|
+
*/
|
|
68
|
+
constructor({ db, config, logger, claudeBridge, bot, rateLimiters }) {
|
|
69
|
+
this.db = db;
|
|
70
|
+
this.config = config;
|
|
71
|
+
this.logger = logger;
|
|
72
|
+
this.claudeBridge = claudeBridge;
|
|
73
|
+
this.bot = bot;
|
|
74
|
+
this.rateLimiters = rateLimiters;
|
|
75
|
+
|
|
76
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
77
|
+
this._timer = null;
|
|
78
|
+
|
|
79
|
+
/** Whether the worker loop is active. */
|
|
80
|
+
this._running = false;
|
|
81
|
+
|
|
82
|
+
/** Guard to prevent overlapping iterations. */
|
|
83
|
+
this._processing = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Start the periodic scheduler worker loop.
|
|
88
|
+
* Initializes next_run_at for any tasks that need it, then begins polling.
|
|
89
|
+
*/
|
|
90
|
+
async start() {
|
|
91
|
+
if (this._running) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this._running = true;
|
|
96
|
+
this.logger.info('scheduler', 'Starting background scheduler worker.');
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await this._initializeNextRuns();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
this.logger.error('scheduler', `Failed to initialize next runs: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this._scheduleNext();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Stop the worker loop gracefully. Any in-flight iteration will finish
|
|
109
|
+
* before the loop fully halts.
|
|
110
|
+
*/
|
|
111
|
+
stop() {
|
|
112
|
+
this._running = false;
|
|
113
|
+
|
|
114
|
+
if (this._timer !== null) {
|
|
115
|
+
clearTimeout(this._timer);
|
|
116
|
+
this._timer = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.logger.info('scheduler', 'Scheduler worker stopped.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Internal
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* On startup, compute next_run_at for tasks that have NULL or past values.
|
|
128
|
+
* Tasks missed during downtime skip ahead to the next valid time (no
|
|
129
|
+
* retroactive execution).
|
|
130
|
+
*/
|
|
131
|
+
async _initializeNextRuns() {
|
|
132
|
+
const result = await this.db.query(
|
|
133
|
+
`SELECT id, cron_expression, timezone
|
|
134
|
+
FROM scheduled_tasks
|
|
135
|
+
WHERE enabled = TRUE
|
|
136
|
+
AND (next_run_at IS NULL OR next_run_at < NOW())`,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (result.rows.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.logger.info('scheduler', `Recomputing next_run_at for ${result.rows.length} task(s).`);
|
|
144
|
+
|
|
145
|
+
for (const row of result.rows) {
|
|
146
|
+
try {
|
|
147
|
+
if (!cron.validate(row.cron_expression)) {
|
|
148
|
+
this.logger.warn('scheduler', `Invalid cron expression for task ${row.id}: "${row.cron_expression}"; skipping.`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const nextRun = nextCronDate(row.cron_expression, new Date(), row.timezone);
|
|
153
|
+
if (nextRun) {
|
|
154
|
+
await this.db.query(
|
|
155
|
+
'UPDATE scheduled_tasks SET next_run_at = $1, updated_at = NOW() WHERE id = $2',
|
|
156
|
+
[nextRun.toISOString(), row.id],
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
this.logger.warn('scheduler', `Failed to compute next_run_at for task ${row.id}: ${err.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Schedule the next processing iteration after POLL_INTERVAL_MS.
|
|
167
|
+
*/
|
|
168
|
+
_scheduleNext() {
|
|
169
|
+
if (!this._running) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this._timer = setTimeout(async () => {
|
|
174
|
+
this._timer = null;
|
|
175
|
+
|
|
176
|
+
// Skip if the previous iteration is still running
|
|
177
|
+
if (this._processing) {
|
|
178
|
+
this._scheduleNext();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Skip if Claude is busy with a user message
|
|
183
|
+
if (this.claudeBridge.isActive()) {
|
|
184
|
+
this.logger.debug('scheduler', 'Skipping tick: Claude subprocess is active.');
|
|
185
|
+
this._scheduleNext();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
this._processing = true;
|
|
191
|
+
await this._processQueue();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.logger.error('scheduler', `Unexpected error in worker loop: ${err.message}`);
|
|
194
|
+
} finally {
|
|
195
|
+
this._processing = false;
|
|
196
|
+
this._scheduleNext();
|
|
197
|
+
}
|
|
198
|
+
}, POLL_INTERVAL_MS);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Fetch and execute tasks whose next_run_at has passed.
|
|
203
|
+
*/
|
|
204
|
+
async _processQueue() {
|
|
205
|
+
const result = await this.db.query(
|
|
206
|
+
`SELECT id, chat_id, cron_expression, task_prompt, description, timezone, error_count
|
|
207
|
+
FROM scheduled_tasks
|
|
208
|
+
WHERE enabled = TRUE
|
|
209
|
+
AND next_run_at IS NOT NULL
|
|
210
|
+
AND next_run_at <= NOW()
|
|
211
|
+
ORDER BY next_run_at ASC
|
|
212
|
+
LIMIT $1`,
|
|
213
|
+
[MAX_TASKS_PER_TICK],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (result.rows.length === 0) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.logger.info('scheduler', `Processing ${result.rows.length} scheduled task(s).`);
|
|
221
|
+
|
|
222
|
+
for (const row of result.rows) {
|
|
223
|
+
// Re-check if Claude became active between tasks
|
|
224
|
+
if (this.claudeBridge.isActive()) {
|
|
225
|
+
this.logger.debug('scheduler', 'Pausing task execution: Claude became active.');
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await this._executeTask(row);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
this.logger.error('scheduler', `Failed to execute task ${row.id}: ${err.message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Execute a single scheduled task: invoke Claude, send the response,
|
|
239
|
+
* and update next_run_at.
|
|
240
|
+
*
|
|
241
|
+
* @param {object} row - Database row from scheduled_tasks
|
|
242
|
+
*/
|
|
243
|
+
async _executeTask(row) {
|
|
244
|
+
const { id, chat_id, cron_expression, task_prompt, description, timezone, error_count } = row;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
// Respect the Claude rate limiter
|
|
248
|
+
await this.rateLimiters.claude.acquire();
|
|
249
|
+
|
|
250
|
+
// Show typing indicator
|
|
251
|
+
await this.bot.sendTyping(chat_id);
|
|
252
|
+
|
|
253
|
+
// Invoke Claude with a fresh session
|
|
254
|
+
const systemPrompt = `You are executing a scheduled task. Task description: ${description}. Provide a helpful, concise response.`;
|
|
255
|
+
const result = await this.claudeBridge.invoke(task_prompt, null, systemPrompt);
|
|
256
|
+
|
|
257
|
+
// Deliver the response
|
|
258
|
+
const responseText = result.text || 'Scheduled task completed (no output).';
|
|
259
|
+
const header = `[Scheduled: ${description}]\n\n`;
|
|
260
|
+
await this.bot.sendMessage(chat_id, header + responseText, {
|
|
261
|
+
parse_mode: undefined,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Compute the next run time
|
|
265
|
+
const nextRun = nextCronDate(cron_expression, new Date(), timezone);
|
|
266
|
+
|
|
267
|
+
await this.db.query(
|
|
268
|
+
`UPDATE scheduled_tasks
|
|
269
|
+
SET last_run_at = NOW(),
|
|
270
|
+
next_run_at = $1,
|
|
271
|
+
error_count = 0,
|
|
272
|
+
last_error = NULL,
|
|
273
|
+
updated_at = NOW()
|
|
274
|
+
WHERE id = $2`,
|
|
275
|
+
[nextRun ? nextRun.toISOString() : null, id],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
this.logger.info('scheduler', `Task ${id} ("${description}") executed. Next run: ${nextRun?.toISOString() || 'none'}`);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// Record the error and possibly auto-disable
|
|
281
|
+
const newErrorCount = error_count + 1;
|
|
282
|
+
const shouldDisable = newErrorCount >= MAX_CONSECUTIVE_ERRORS;
|
|
283
|
+
|
|
284
|
+
// Still compute next_run_at so a re-enabled task doesn't fire immediately
|
|
285
|
+
let nextRun = null;
|
|
286
|
+
try {
|
|
287
|
+
nextRun = nextCronDate(cron_expression, new Date(), timezone);
|
|
288
|
+
} catch { /* ignore parse errors during error handling */ }
|
|
289
|
+
|
|
290
|
+
await this.db.query(
|
|
291
|
+
`UPDATE scheduled_tasks
|
|
292
|
+
SET error_count = $1,
|
|
293
|
+
last_error = $2,
|
|
294
|
+
enabled = $3,
|
|
295
|
+
next_run_at = $4,
|
|
296
|
+
updated_at = NOW()
|
|
297
|
+
WHERE id = $5`,
|
|
298
|
+
[newErrorCount, err.message, !shouldDisable, nextRun ? nextRun.toISOString() : null, id],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (shouldDisable) {
|
|
302
|
+
this.logger.warn('scheduler', `Task ${id} auto-disabled after ${MAX_CONSECUTIVE_ERRORS} consecutive errors.`);
|
|
303
|
+
|
|
304
|
+
// Notify the user
|
|
305
|
+
try {
|
|
306
|
+
await this.bot.sendMessage(
|
|
307
|
+
chat_id,
|
|
308
|
+
`Your scheduled task "${description}" has been disabled after ${MAX_CONSECUTIVE_ERRORS} consecutive failures.\n\nLast error: ${err.message}\n\nUse /schedule to re-enable it.`,
|
|
309
|
+
{ parse_mode: undefined },
|
|
310
|
+
);
|
|
311
|
+
} catch { /* best-effort notification */ }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export { SchedulerWorker, nextCronDate };
|
|
320
|
+
export default SchedulerWorker;
|
package/src/telegram/commands.js
CHANGED
|
@@ -4,6 +4,13 @@ import { escapeMarkdownV2 } from './bot.js';
|
|
|
4
4
|
/** How long a confirmation remains valid (ms). */
|
|
5
5
|
const CONFIRMATION_TTL_MS = 60_000;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Commands that are forwarded to Claude instead of handled by the router.
|
|
9
|
+
* The corresponding Claude skill activates on the `/command` prefix.
|
|
10
|
+
* @type {Set<string>}
|
|
11
|
+
*/
|
|
12
|
+
const PASSTHROUGH_COMMANDS = new Set(['/project', '/journal', '/knowledge', '/schedule']);
|
|
13
|
+
|
|
7
14
|
/**
|
|
8
15
|
* Descriptions shown by the /help command.
|
|
9
16
|
* @type {Record<string, string>}
|
|
@@ -15,6 +22,10 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
15
22
|
'/restart': 'Restart the service process',
|
|
16
23
|
'/reboot': 'Reboot the host operating system',
|
|
17
24
|
'/new': 'Start a new conversation session',
|
|
25
|
+
'/project': 'Manage projects, specs, and issues (handled by Claude)',
|
|
26
|
+
'/journal': 'Record and search journal entries (handled by Claude)',
|
|
27
|
+
'/knowledge': 'Manage knowledge graph (handled by Claude)',
|
|
28
|
+
'/schedule': 'Create, list, or manage scheduled tasks (handled by Claude)',
|
|
18
29
|
'/help': 'List available commands',
|
|
19
30
|
};
|
|
20
31
|
|
|
@@ -91,6 +102,11 @@ class CommandRouter {
|
|
|
91
102
|
// Extract the command name (everything up to the first space or @)
|
|
92
103
|
const command = trimmed.split(/[\s@]/)[0].toLowerCase();
|
|
93
104
|
|
|
105
|
+
// Skill-managed commands pass through to Claude
|
|
106
|
+
if (PASSTHROUGH_COMMANDS.has(command)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
const replyOpts = { reply_to_message_id: messageId, parse_mode: undefined };
|
|
95
111
|
|
|
96
112
|
try {
|
|
@@ -436,5 +452,5 @@ class CommandRouter {
|
|
|
436
452
|
}
|
|
437
453
|
}
|
|
438
454
|
|
|
439
|
-
export { CommandRouter, COMMAND_DESCRIPTIONS };
|
|
455
|
+
export { CommandRouter, COMMAND_DESCRIPTIONS, PASSTHROUGH_COMMANDS };
|
|
440
456
|
export default CommandRouter;
|
package/src/web/server.js
CHANGED
|
@@ -187,8 +187,10 @@ class WebServer {
|
|
|
187
187
|
'SELECT COUNT(*)::int AS count FROM conversation_messages',
|
|
188
188
|
);
|
|
189
189
|
data.messageCount = countRes.rows[0]?.count ?? 0;
|
|
190
|
-
} catch {
|
|
190
|
+
} catch (err) {
|
|
191
191
|
data.dbAvailable = false;
|
|
192
|
+
data.dbError = diagnosePgError(err);
|
|
193
|
+
this._logger.error('web', `Dashboard DB error: ${err.message} (code=${err.code || 'none'})`);
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
if (data.dbAvailable) {
|
|
@@ -299,14 +301,19 @@ class WebServer {
|
|
|
299
301
|
|
|
300
302
|
try {
|
|
301
303
|
await this._db.query('SELECT 1');
|
|
302
|
-
} catch {
|
|
303
|
-
health.components.database =
|
|
304
|
+
} catch (err) {
|
|
305
|
+
health.components.database = {
|
|
306
|
+
status: 'error',
|
|
307
|
+
error: err.message || 'Unknown error',
|
|
308
|
+
code: err.code || undefined,
|
|
309
|
+
};
|
|
304
310
|
}
|
|
305
311
|
|
|
306
312
|
// Derive overall status from component states
|
|
307
313
|
const states = Object.values(health.components);
|
|
308
|
-
|
|
309
|
-
|
|
314
|
+
const isErr = (s) => s === 'error' || (typeof s === 'object' && s.status === 'error');
|
|
315
|
+
if (states.some(isErr)) {
|
|
316
|
+
health.status = states.every(isErr) ? 'error' : 'degraded';
|
|
310
317
|
}
|
|
311
318
|
|
|
312
319
|
const httpStatus = health.status === 'error' ? 503 : 200;
|
|
@@ -351,7 +358,7 @@ class WebServer {
|
|
|
351
358
|
|
|
352
359
|
const tablesResult = await this._db.query(`
|
|
353
360
|
SELECT
|
|
354
|
-
relname AS name,
|
|
361
|
+
c.relname AS name,
|
|
355
362
|
n_live_tup AS row_count,
|
|
356
363
|
pg_size_pretty(pg_total_relation_size(c.oid)) AS size
|
|
357
364
|
FROM pg_class c
|
|
@@ -372,7 +379,9 @@ class WebServer {
|
|
|
372
379
|
data.dbSize = sizeResult.rows[0]?.size || '';
|
|
373
380
|
} catch (err) {
|
|
374
381
|
data.dbAvailable = false;
|
|
375
|
-
|
|
382
|
+
data.dbError = diagnosePgError(err);
|
|
383
|
+
data.dbUrl = maskDatabaseUrl(this._config.DATABASE_URL);
|
|
384
|
+
this._logger.error('web', `Database page error: ${err.message} (code=${err.code || 'none'})`);
|
|
376
385
|
}
|
|
377
386
|
|
|
378
387
|
res.send(databaseHTML(data));
|
|
@@ -499,6 +508,83 @@ function maskValue(value) {
|
|
|
499
508
|
return s.slice(0, 4) + '*'.repeat(Math.min(s.length - 8, 20)) + s.slice(-4);
|
|
500
509
|
}
|
|
501
510
|
|
|
511
|
+
/** Mask the password in a PostgreSQL connection URL for safe display. */
|
|
512
|
+
function maskDatabaseUrl(url) {
|
|
513
|
+
if (!url) return '(not set)';
|
|
514
|
+
const schemeEnd = url.indexOf('://');
|
|
515
|
+
const lastAt = url.lastIndexOf('@');
|
|
516
|
+
if (schemeEnd >= 0 && lastAt > schemeEnd) {
|
|
517
|
+
const userInfo = url.slice(schemeEnd + 3, lastAt);
|
|
518
|
+
const colonPos = userInfo.indexOf(':');
|
|
519
|
+
if (colonPos >= 0) {
|
|
520
|
+
return url.slice(0, schemeEnd + 3 + colonPos + 1) + '****' + url.slice(lastAt);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return maskValue(url);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Extract diagnostic information from a pg / Node.js connection error.
|
|
528
|
+
* Returns { message, code, detail, hint, diagnosis }.
|
|
529
|
+
*/
|
|
530
|
+
function diagnosePgError(err) {
|
|
531
|
+
const info = {
|
|
532
|
+
message: err.message || 'Unknown error',
|
|
533
|
+
code: err.code || '',
|
|
534
|
+
detail: err.detail || '',
|
|
535
|
+
hint: err.hint || '',
|
|
536
|
+
diagnosis: '',
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
switch (err.code) {
|
|
540
|
+
case 'ECONNREFUSED':
|
|
541
|
+
info.diagnosis = 'Cannot connect to the database server. Verify the host and port are correct and that PostgreSQL is running.';
|
|
542
|
+
break;
|
|
543
|
+
case 'ENOTFOUND':
|
|
544
|
+
info.diagnosis = 'DNS lookup failed. The database hostname could not be resolved. Check the host in your DATABASE_URL.';
|
|
545
|
+
break;
|
|
546
|
+
case 'ETIMEDOUT':
|
|
547
|
+
info.diagnosis = 'Connection timed out. The database server may be unreachable or behind a firewall.';
|
|
548
|
+
break;
|
|
549
|
+
case 'ECONNRESET':
|
|
550
|
+
info.diagnosis = 'Connection was reset by the server. This may indicate a network issue or server restart.';
|
|
551
|
+
break;
|
|
552
|
+
default:
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!info.diagnosis && typeof err.code === 'string' && err.code.length === 5) {
|
|
557
|
+
const cls = err.code.substring(0, 2);
|
|
558
|
+
switch (cls) {
|
|
559
|
+
case '08':
|
|
560
|
+
info.diagnosis = 'Connection exception. The database server rejected or dropped the connection.';
|
|
561
|
+
break;
|
|
562
|
+
case '28':
|
|
563
|
+
info.diagnosis = 'Authentication failed. Check the username and password in your DATABASE_URL.';
|
|
564
|
+
break;
|
|
565
|
+
case '3D':
|
|
566
|
+
info.diagnosis = 'The specified database does not exist. Verify the database name in your DATABASE_URL.';
|
|
567
|
+
break;
|
|
568
|
+
case '53':
|
|
569
|
+
info.diagnosis = 'The database server has insufficient resources (too many connections, out of memory, or disk full).';
|
|
570
|
+
break;
|
|
571
|
+
case '57':
|
|
572
|
+
info.diagnosis = 'The database server is shutting down or not accepting connections.';
|
|
573
|
+
break;
|
|
574
|
+
default:
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!info.diagnosis && err.message) {
|
|
580
|
+
if (/ssl/i.test(err.message) || /certificate/i.test(err.message)) {
|
|
581
|
+
info.diagnosis = 'SSL/TLS error. Check your SSL configuration or try adding ?sslmode=require or ?sslmode=no-verify to your DATABASE_URL.';
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return info;
|
|
586
|
+
}
|
|
587
|
+
|
|
502
588
|
// ---------------------------------------------------------------------------
|
|
503
589
|
// HTML template functions
|
|
504
590
|
// ---------------------------------------------------------------------------
|
|
@@ -670,6 +756,28 @@ function layoutHTML(title, content) {
|
|
|
670
756
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
|
671
757
|
margin-bottom: 0.25rem;
|
|
672
758
|
}
|
|
759
|
+
.secret-input-wrapper {
|
|
760
|
+
position: relative;
|
|
761
|
+
}
|
|
762
|
+
.secret-input-wrapper input {
|
|
763
|
+
padding-right: 2.5rem;
|
|
764
|
+
}
|
|
765
|
+
.secret-toggle {
|
|
766
|
+
position: absolute;
|
|
767
|
+
right: 0.4rem;
|
|
768
|
+
top: 50%;
|
|
769
|
+
transform: translateY(-50%);
|
|
770
|
+
background: none;
|
|
771
|
+
border: none;
|
|
772
|
+
color: #8b949e;
|
|
773
|
+
cursor: pointer;
|
|
774
|
+
padding: 0.2rem;
|
|
775
|
+
font-size: 0.85rem;
|
|
776
|
+
line-height: 1;
|
|
777
|
+
}
|
|
778
|
+
.secret-toggle:hover {
|
|
779
|
+
color: #c9d1d9;
|
|
780
|
+
}
|
|
673
781
|
.checkbox-group {
|
|
674
782
|
display: flex;
|
|
675
783
|
align-items: center;
|
|
@@ -828,9 +936,17 @@ function dashboardHTML(data) {
|
|
|
828
936
|
</div>`;
|
|
829
937
|
|
|
830
938
|
// -- DB warning ----------------------------------------------------------
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
939
|
+
let dbWarn = '';
|
|
940
|
+
if (!data.dbAvailable) {
|
|
941
|
+
const e = data.dbError || {};
|
|
942
|
+
dbWarn = '<div class="db-warn">Database is unavailable. Dashboard data may be incomplete.';
|
|
943
|
+
if (e.diagnosis) {
|
|
944
|
+
dbWarn += `<br><span style="font-size:0.82rem;">${esc(e.diagnosis)}</span>`;
|
|
945
|
+
} else if (e.message) {
|
|
946
|
+
dbWarn += `<br><span style="font-size:0.82rem;">${esc(e.message)}</span>`;
|
|
947
|
+
}
|
|
948
|
+
dbWarn += '</div>';
|
|
949
|
+
}
|
|
834
950
|
|
|
835
951
|
// -- Recent messages -----------------------------------------------------
|
|
836
952
|
let messagesSection;
|
|
@@ -900,15 +1016,23 @@ function settingsHTML(config, message) {
|
|
|
900
1016
|
let inputArea;
|
|
901
1017
|
|
|
902
1018
|
if (field.secret) {
|
|
1019
|
+
const maskedDisplay = field.key === 'DATABASE_URL'
|
|
1020
|
+
? maskDatabaseUrl(String(value))
|
|
1021
|
+
: maskValue(String(value));
|
|
903
1022
|
const status = value
|
|
904
|
-
? `Current: ${esc(
|
|
1023
|
+
? `Current: ${esc(maskedDisplay)}`
|
|
905
1024
|
: '(not set)';
|
|
906
1025
|
inputArea = `
|
|
907
1026
|
<div>
|
|
908
1027
|
<div class="secret-current">${status}</div>
|
|
909
|
-
<
|
|
910
|
-
|
|
911
|
-
|
|
1028
|
+
<div class="secret-input-wrapper">
|
|
1029
|
+
<input type="password" id="input-${esc(field.key)}" name="${esc(field.key)}"
|
|
1030
|
+
value="" placeholder="Enter new value to change"
|
|
1031
|
+
autocomplete="off" />
|
|
1032
|
+
<button type="button" class="secret-toggle"
|
|
1033
|
+
onclick="const inp=document.getElementById('input-${esc(field.key)}');const show=inp.type==='password';inp.type=show?'text':'password';this.textContent=show?'◉':'◎';"
|
|
1034
|
+
title="Toggle visibility">◎</button>
|
|
1035
|
+
</div>
|
|
912
1036
|
</div>`;
|
|
913
1037
|
} else if (field.type === 'boolean') {
|
|
914
1038
|
const checked = String(value).toLowerCase() === 'true' ? ' checked' : '';
|
|
@@ -991,9 +1115,29 @@ function databaseHTML(data) {
|
|
|
991
1115
|
? `<div class="alert alert-${esc(data.message.type)}">${esc(data.message.text)}</div>`
|
|
992
1116
|
: '';
|
|
993
1117
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1118
|
+
let dbWarn = '';
|
|
1119
|
+
if (!data.dbAvailable) {
|
|
1120
|
+
const e = data.dbError || {};
|
|
1121
|
+
dbWarn = '<div class="db-warn">';
|
|
1122
|
+
dbWarn += '<strong>Database is unavailable.</strong> Cannot retrieve database information.';
|
|
1123
|
+
if (e.diagnosis) {
|
|
1124
|
+
dbWarn += `<br><br><strong>Diagnosis:</strong> ${esc(e.diagnosis)}`;
|
|
1125
|
+
}
|
|
1126
|
+
dbWarn += `<br><br><strong>Error:</strong> ${esc(e.message || 'Unknown')}`;
|
|
1127
|
+
if (e.code) {
|
|
1128
|
+
dbWarn += ` <span class="muted">(code: ${esc(e.code)})</span>`;
|
|
1129
|
+
}
|
|
1130
|
+
if (e.detail) {
|
|
1131
|
+
dbWarn += `<br><strong>Detail:</strong> ${esc(e.detail)}`;
|
|
1132
|
+
}
|
|
1133
|
+
if (e.hint) {
|
|
1134
|
+
dbWarn += `<br><strong>Hint:</strong> ${esc(e.hint)}`;
|
|
1135
|
+
}
|
|
1136
|
+
if (data.dbUrl) {
|
|
1137
|
+
dbWarn += `<br><br><strong>Connection URL:</strong> <code style="font-size:0.82rem;">${esc(data.dbUrl)}</code>`;
|
|
1138
|
+
}
|
|
1139
|
+
dbWarn += '</div>';
|
|
1140
|
+
}
|
|
997
1141
|
|
|
998
1142
|
const stats = data.dbAvailable
|
|
999
1143
|
? `
|