@polderlabs/bizar 4.7.0 → 4.7.2
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/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
- package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
- package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/publishing/SKILL.md +146 -0
- package/bizar-dash/src/server/api.mjs +8 -0
- package/bizar-dash/src/server/backup-store.mjs +525 -0
- package/bizar-dash/src/server/digest-store.mjs +558 -0
- package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
- package/bizar-dash/src/server/routes/backup.mjs +112 -0
- package/bizar-dash/src/server/routes/chat.mjs +14 -0
- package/bizar-dash/src/server/routes/digests.mjs +82 -0
- package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
- package/bizar-dash/src/server/schedules-runner.mjs +126 -0
- package/bizar-dash/src/server/server.mjs +17 -0
- package/bizar-dash/src/web/App.tsx +8 -1
- package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
- package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
- package/bizar-dash/src/web/styles/main.css +70 -8
- package/bizar-dash/src/web/views/Activity.tsx +11 -1
- package/bizar-dash/src/web/views/Agents.tsx +57 -42
- package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
- package/bizar-dash/src/web/views/Chat.tsx +8 -0
- package/bizar-dash/src/web/views/History.tsx +4 -1
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
- package/bizar-dash/src/web/views/Mods.tsx +30 -17
- package/bizar-dash/src/web/views/Overview.tsx +8 -1
- package/bizar-dash/src/web/views/Providers.tsx +16 -16
- package/bizar-dash/src/web/views/Schedules.tsx +33 -15
- package/bizar-dash/src/web/views/Settings.tsx +97 -1751
- package/bizar-dash/src/web/views/Skills.tsx +4 -1
- package/bizar-dash/src/web/views/Tasks.tsx +11 -2
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
- package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
- package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
- package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
- package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
- package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
- package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
- package/bizar-dash/tests/a11y.test.tsx +206 -0
- package/bizar-dash/tests/backup-restore.test.mjs +217 -0
- package/bizar-dash/tests/backup-restore.test.tsx +123 -0
- package/bizar-dash/tests/backup-store.test.mjs +300 -0
- package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
- package/bizar-dash/tests/digest-generation.test.mjs +191 -0
- package/bizar-dash/tests/digest-store.test.mjs +264 -0
- package/bizar-dash/tests/rate-limit.test.mjs +298 -0
- package/cli/bin.mjs +96 -2
- package/cli/commands/minimax.mjs +20 -0
- package/cli/commands/util.mjs +154 -1
- package/cli/digest.mjs +149 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
- package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
- package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +0 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/backup.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.8.0 — Backup/restore REST surface.
|
|
5
|
+
*
|
|
6
|
+
* Endpoints:
|
|
7
|
+
* GET /api/backup/list — list available backups
|
|
8
|
+
* POST /api/backup/create — create a new backup
|
|
9
|
+
* POST /api/backup/restore — restore from a backup
|
|
10
|
+
* POST /api/backup/verify — verify backup integrity
|
|
11
|
+
* DELETE /api/backup/:path — delete a backup
|
|
12
|
+
* GET /api/backup/manifest — get manifest for a backup
|
|
13
|
+
*/
|
|
14
|
+
import { Router } from 'express';
|
|
15
|
+
import { wrap } from './_shared.mjs';
|
|
16
|
+
import {
|
|
17
|
+
createBackup,
|
|
18
|
+
listBackups,
|
|
19
|
+
restoreBackup,
|
|
20
|
+
deleteBackup,
|
|
21
|
+
verifyBackup,
|
|
22
|
+
getManifest,
|
|
23
|
+
} from '../backup-store.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} deps
|
|
27
|
+
* @param {string} deps.projectRoot
|
|
28
|
+
* @returns {import('express').Router}
|
|
29
|
+
*/
|
|
30
|
+
export function createBackupRouter({ projectRoot }) {
|
|
31
|
+
const router = Router();
|
|
32
|
+
|
|
33
|
+
// GET /api/backup/list
|
|
34
|
+
router.get('/backup/list', wrap(async (_req, res) => {
|
|
35
|
+
const backups = await listBackups();
|
|
36
|
+
res.json({ ok: true, backups });
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// POST /api/backup/create
|
|
40
|
+
router.post('/backup/create', wrap(async (req, res) => {
|
|
41
|
+
const { label, includeProject } = req.body || {};
|
|
42
|
+
const result = await createBackup({
|
|
43
|
+
label: label || null,
|
|
44
|
+
projectRoot: includeProject ? projectRoot : null,
|
|
45
|
+
});
|
|
46
|
+
res.json(result);
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// POST /api/backup/restore
|
|
50
|
+
router.post('/backup/restore', wrap(async (req, res) => {
|
|
51
|
+
const { backupPath, dryRun, conflictStrategy, includeProject } = req.body || {};
|
|
52
|
+
if (!backupPath) {
|
|
53
|
+
const err = new Error('backupPath is required');
|
|
54
|
+
err.status = 400;
|
|
55
|
+
err.code = 'bad_request';
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
const result = await restoreBackup({
|
|
59
|
+
backupPath,
|
|
60
|
+
dryRun: Boolean(dryRun),
|
|
61
|
+
conflictStrategy: conflictStrategy || 'merge',
|
|
62
|
+
projectRoot: includeProject ? projectRoot : null,
|
|
63
|
+
});
|
|
64
|
+
res.json(result);
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// POST /api/backup/verify
|
|
68
|
+
router.post('/backup/verify', wrap(async (req, res) => {
|
|
69
|
+
const { backupPath } = req.body || {};
|
|
70
|
+
if (!backupPath) {
|
|
71
|
+
const err = new Error('backupPath is required');
|
|
72
|
+
err.status = 400;
|
|
73
|
+
err.code = 'bad_request';
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
const result = await verifyBackup({ backupPath });
|
|
77
|
+
res.json(result);
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
// DELETE /api/backup/:path (path is url-encoded)
|
|
81
|
+
router.delete('/backup/:path', wrap(async (req, res) => {
|
|
82
|
+
const { path: encoded } = req.params;
|
|
83
|
+
const backupPath = decodeURIComponent(encoded);
|
|
84
|
+
const result = await deleteBackup({ backupPath });
|
|
85
|
+
if (!result.ok) {
|
|
86
|
+
const err = new Error(result.error || 'Delete failed');
|
|
87
|
+
err.status = 404;
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
res.json(result);
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
// GET /api/backup/manifest
|
|
94
|
+
router.get('/backup/manifest', wrap(async (req, res) => {
|
|
95
|
+
const { backupPath } = req.query || {};
|
|
96
|
+
if (!backupPath) {
|
|
97
|
+
const err = new Error('backupPath query param is required');
|
|
98
|
+
err.status = 400;
|
|
99
|
+
err.code = 'bad_request';
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
const result = await getManifest({ backupPath });
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
const err = new Error(result.error || 'Manifest not found');
|
|
105
|
+
err.status = 404;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
res.json(result);
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
return router;
|
|
112
|
+
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
buildAuthHeader,
|
|
45
45
|
} from '../serve-info.mjs';
|
|
46
46
|
import { wrap } from './_shared.mjs';
|
|
47
|
+
import { createRateLimiter } from '../lib/rate-limit.mjs';
|
|
47
48
|
|
|
48
49
|
const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,120}$/;
|
|
49
50
|
|
|
@@ -95,6 +96,19 @@ function resetChatDeltaCount(chatSessionId) {
|
|
|
95
96
|
export function createChatRouter({ state, broadcast }) {
|
|
96
97
|
const router = Router();
|
|
97
98
|
|
|
99
|
+
// v4.8.0 — Per-IP token bucket. Chat endpoints are the most expensive
|
|
100
|
+
// thing the dashboard does (each POST kicks off an SSE subscription +
|
|
101
|
+
// opencode prompt dispatch), so the default budget is conservative:
|
|
102
|
+
// 60 requests / minute / IP, refilling at 1 token per second.
|
|
103
|
+
// Operators can tune via BIZAR_RATE_LIMIT_CHAT_CAPACITY /
|
|
104
|
+
// BIZAR_RATE_LIMIT_CHAT_REFILL.
|
|
105
|
+
const chatLimiter = createRateLimiter({
|
|
106
|
+
capacity: parseInt(process.env.BIZAR_RATE_LIMIT_CHAT_CAPACITY || '60', 10),
|
|
107
|
+
refillPerSecond: parseFloat(process.env.BIZAR_RATE_LIMIT_CHAT_REFILL || '1'),
|
|
108
|
+
scope: 'chat',
|
|
109
|
+
});
|
|
110
|
+
router.use(chatLimiter);
|
|
111
|
+
|
|
98
112
|
router.get('/chat', wrap(async (req, res) => {
|
|
99
113
|
const sessionId = req.query.session ? String(req.query.session) : null;
|
|
100
114
|
const requestedLimit = req.query.limit ? Number(req.query.limit) : 200;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/digests.mjs
|
|
3
|
+
*
|
|
4
|
+
* /api/digests — list digests
|
|
5
|
+
* /api/digests/generate (POST) — generate a digest
|
|
6
|
+
* /api/digests/:path (GET) — get a specific digest
|
|
7
|
+
* /api/digests/:path (DELETE) — delete a digest
|
|
8
|
+
*
|
|
9
|
+
* The `:path` parameter is URL-encoded so it survives Express routing.
|
|
10
|
+
*/
|
|
11
|
+
import { Router } from 'express';
|
|
12
|
+
import {
|
|
13
|
+
generateAndSave,
|
|
14
|
+
listDigests,
|
|
15
|
+
getDigest,
|
|
16
|
+
deleteDigest,
|
|
17
|
+
} from '../digest-store.mjs';
|
|
18
|
+
import { readActiveProjectId, wrap } from './_shared.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} deps
|
|
22
|
+
* @param {object} [deps.projectRoot]
|
|
23
|
+
* @returns {import('express').Router}
|
|
24
|
+
*/
|
|
25
|
+
export function createDigestsRouter({ projectRoot } = {}) {
|
|
26
|
+
const router = Router();
|
|
27
|
+
|
|
28
|
+
// GET /api/digests — list all digests
|
|
29
|
+
router.get('/digests', wrap(async (req, res) => {
|
|
30
|
+
const limit = parseInt(req.query.limit, 10) || 20;
|
|
31
|
+
const digests = await listDigests({ limit });
|
|
32
|
+
res.json({ digests });
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// GET /api/digests/:path — get a single digest by path
|
|
36
|
+
// The path is double-encoded: first by the client, then by Express.
|
|
37
|
+
router.get('/digests/*', wrap(async (req, res) => {
|
|
38
|
+
// Reconstruct the path from the wildcard
|
|
39
|
+
const rawPath = req.params[0];
|
|
40
|
+
if (!rawPath) {
|
|
41
|
+
// This catches /api/digests with no trailing path (should be handled
|
|
42
|
+
// by the /digests route above, but Express may match this first if the
|
|
43
|
+
// wildcard is greedy — we handle the list case explicitly).
|
|
44
|
+
const digests = await listDigests({ limit: 20 });
|
|
45
|
+
res.json({ digests });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const filePath = decodeURIComponent(rawPath);
|
|
49
|
+
const digest = await getDigest(filePath);
|
|
50
|
+
if (!digest) {
|
|
51
|
+
res.status(404).json({ error: 'not_found', message: 'Digest not found' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
res.json(digest);
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// POST /api/digests/generate — generate a new digest
|
|
58
|
+
router.post('/digests/generate', wrap(async (req, res) => {
|
|
59
|
+
const { weekStart, weekEnd, dryRun } = req.body || {};
|
|
60
|
+
const root = req.body?.projectRoot || projectRoot || process.cwd();
|
|
61
|
+
const result = await generateAndSave({ weekStart, weekEnd, projectRoot: root, dryRun });
|
|
62
|
+
res.status(dryRun ? 200 : 201).json(result);
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// DELETE /api/digests/* — delete a digest by path
|
|
66
|
+
router.delete('/digests/*', wrap(async (req, res) => {
|
|
67
|
+
const rawPath = req.params[0];
|
|
68
|
+
if (!rawPath) {
|
|
69
|
+
res.status(400).json({ error: 'bad_request', message: 'Digest path required' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const filePath = decodeURIComponent(rawPath);
|
|
73
|
+
const result = await deleteDigest(filePath);
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
res.status(404).json({ error: 'not_found', message: result.error || 'Digest not found' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
res.status(204).end();
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
return router;
|
|
82
|
+
}
|
|
@@ -14,10 +14,24 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import express from 'express';
|
|
17
|
+
import { createRateLimiter } from '../lib/rate-limit.mjs';
|
|
17
18
|
|
|
18
19
|
export function createV2EventsRouter({ eventBus }) {
|
|
19
20
|
const router = express.Router();
|
|
20
21
|
|
|
22
|
+
// v4.8.0 — Per-IP token bucket. The v2 event endpoint accepts
|
|
23
|
+
// arbitrary publishes from the opencode plugin, so we cap it
|
|
24
|
+
// higher than chat to keep automation pipelines flowing under
|
|
25
|
+
// burst. Defaults: 120 requests / minute / IP, 2 tokens/sec refill.
|
|
26
|
+
// Operators can tune via BIZAR_RATE_LIMIT_EVENT_CAPACITY /
|
|
27
|
+
// BIZAR_RATE_LIMIT_EVENT_REFILL.
|
|
28
|
+
const eventLimiter = createRateLimiter({
|
|
29
|
+
capacity: parseInt(process.env.BIZAR_RATE_LIMIT_EVENT_CAPACITY || '120', 10),
|
|
30
|
+
refillPerSecond: parseFloat(process.env.BIZAR_RATE_LIMIT_EVENT_REFILL || '2'),
|
|
31
|
+
scope: 'event',
|
|
32
|
+
});
|
|
33
|
+
router.use(eventLimiter);
|
|
34
|
+
|
|
21
35
|
router.get('/event', (req, res) => {
|
|
22
36
|
res.status(200);
|
|
23
37
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
@@ -23,6 +23,7 @@ import { homedir } from 'node:os';
|
|
|
23
23
|
import { isIP } from 'node:net';
|
|
24
24
|
import { projectsStore } from './projects-store.mjs';
|
|
25
25
|
import { schedulesStore } from './schedules-store.mjs';
|
|
26
|
+
import { Cron } from 'croner';
|
|
26
27
|
|
|
27
28
|
const HOME = homedir();
|
|
28
29
|
const LOG_DIR = join(HOME, '.config', 'bizar');
|
|
@@ -30,6 +31,97 @@ const LOG_FILE = join(LOG_DIR, 'service.log');
|
|
|
30
31
|
const ALLOW_PRIVATE_WEBHOOKS = process.env.BIZAR_DASHBOARD_ALLOW_PRIVATE_WEBHOOKS === '1';
|
|
31
32
|
const SHELL_META = /[;&|`$<>\n\r]/;
|
|
32
33
|
|
|
34
|
+
// ── Internal schedules ─────────────────────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// v4.8.0 — Builtin schedules registered at module load. These are NOT
|
|
37
|
+
// persisted in the schedules-store; they live in-memory here and are
|
|
38
|
+
// checked alongside the project schedules during tick(). When a builtin
|
|
39
|
+
// schedule fires, the runner dispatches directly to the handler without
|
|
40
|
+
// going through the task delegator or action runner.
|
|
41
|
+
|
|
42
|
+
/** @type {Array<{ id: string, name: string, schedule: string, type: string, timezone: string, enabled: boolean, builtin: boolean, handler?: Function, lastRun: string|null, nextRun: string|null }>} */
|
|
43
|
+
const internalSchedules = [];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Register an internal schedule that is evaluated during tick().
|
|
47
|
+
*
|
|
48
|
+
* @param {object} spec
|
|
49
|
+
* @param {string} spec.id
|
|
50
|
+
* @param {string} spec.name
|
|
51
|
+
* @param {string} spec.cron — cron expression
|
|
52
|
+
* @param {string} [spec.timezone='UTC']
|
|
53
|
+
* @param {Function} [spec.handler] — optional direct-call handler for builtin schedules
|
|
54
|
+
*/
|
|
55
|
+
export function registerInternalSchedule(spec) {
|
|
56
|
+
const tz = spec.timezone || 'UTC';
|
|
57
|
+
let nextRun = null;
|
|
58
|
+
if (spec.cron) {
|
|
59
|
+
try {
|
|
60
|
+
const job = new Cron(spec.cron, { timezone: tz });
|
|
61
|
+
const n = job.nextRun();
|
|
62
|
+
nextRun = n ? n.toISOString() : null;
|
|
63
|
+
} catch { /* invalid cron */ }
|
|
64
|
+
}
|
|
65
|
+
// Remove existing entry with same id (for hot-reload)
|
|
66
|
+
const existing = internalSchedules.findIndex((s) => s.id === spec.id);
|
|
67
|
+
const entry = {
|
|
68
|
+
id: spec.id,
|
|
69
|
+
name: spec.name || spec.id,
|
|
70
|
+
schedule: spec.cron || '',
|
|
71
|
+
type: 'cron',
|
|
72
|
+
timezone: tz,
|
|
73
|
+
enabled: true,
|
|
74
|
+
builtin: true,
|
|
75
|
+
handler: spec.handler || null,
|
|
76
|
+
lastRun: null,
|
|
77
|
+
nextRun,
|
|
78
|
+
};
|
|
79
|
+
if (existing >= 0) {
|
|
80
|
+
internalSchedules[existing] = { ...internalSchedules[existing], ...entry };
|
|
81
|
+
} else {
|
|
82
|
+
internalSchedules.push(entry);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Recompute nextRun for all internal schedules (called on system clock
|
|
88
|
+
* changes or after midnight).
|
|
89
|
+
*/
|
|
90
|
+
export function recomputeInternalSchedules() {
|
|
91
|
+
for (const s of internalSchedules) {
|
|
92
|
+
if (s.type === 'cron' && s.schedule) {
|
|
93
|
+
try {
|
|
94
|
+
const job = new Cron(s.schedule, { timezone: s.timezone || 'UTC' });
|
|
95
|
+
const n = job.nextRun();
|
|
96
|
+
s.nextRun = n ? n.toISOString() : null;
|
|
97
|
+
} catch {
|
|
98
|
+
s.nextRun = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// v4.8.0 — Auto-register the weekly digest cron at module load.
|
|
105
|
+
registerInternalSchedule({
|
|
106
|
+
id: 'bizar-internal-weekly-digest',
|
|
107
|
+
name: 'Weekly digest',
|
|
108
|
+
cron: '0 0 * * 0',
|
|
109
|
+
timezone: 'UTC',
|
|
110
|
+
handler: async () => {
|
|
111
|
+
logLine('[digest] Weekly digest cron fired — generating…');
|
|
112
|
+
try {
|
|
113
|
+
const { generateAndSave } = await import('./digest-store.mjs');
|
|
114
|
+
const projectRoot = process.env.BIZAR_PROJECT_ROOT || process.cwd();
|
|
115
|
+
const result = await generateAndSave({ projectRoot });
|
|
116
|
+
logLine(`[digest] Weekly digest generated: ${result.weekStart} (${result.saveResult.paths.length} file(s))`);
|
|
117
|
+
return { ok: true, result };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
logLine(`[digest] Weekly digest generation failed: ${err.message}`);
|
|
120
|
+
return { ok: false, error: err.message };
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
33
125
|
function logLine(line) {
|
|
34
126
|
try {
|
|
35
127
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
@@ -292,6 +384,8 @@ export const schedulesRunner = {
|
|
|
292
384
|
console.warn('swallowed in schedules due-list:', err.message);
|
|
293
385
|
}
|
|
294
386
|
let skipped = 0;
|
|
387
|
+
|
|
388
|
+
// Project schedules
|
|
295
389
|
for (const projectId of projectIds) {
|
|
296
390
|
const due = schedulesStore.due(projectId, Date.now());
|
|
297
391
|
for (const sched of due) {
|
|
@@ -300,6 +394,38 @@ export const schedulesRunner = {
|
|
|
300
394
|
fired.push({ projectId, scheduleId: sched.id, ...result });
|
|
301
395
|
}
|
|
302
396
|
}
|
|
397
|
+
|
|
398
|
+
// v4.8.0 — Internal (builtin) schedules
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
for (const sched of internalSchedules) {
|
|
401
|
+
if (!sched.enabled) continue;
|
|
402
|
+
if (!sched.nextRun) continue;
|
|
403
|
+
if (new Date(sched.nextRun).getTime() > now) continue;
|
|
404
|
+
const startedAt = new Date().toISOString();
|
|
405
|
+
logLine(`[${startedAt}] run internal ${sched.id} (${sched.name})`);
|
|
406
|
+
try {
|
|
407
|
+
if (typeof sched.handler === 'function') {
|
|
408
|
+
const res = await sched.handler();
|
|
409
|
+
sched.lastRun = new Date().toISOString();
|
|
410
|
+
// Recompute nextRun
|
|
411
|
+
if (sched.schedule) {
|
|
412
|
+
try {
|
|
413
|
+
const job = new Cron(sched.schedule, { timezone: sched.timezone || 'UTC' });
|
|
414
|
+
const n = job.nextRun();
|
|
415
|
+
sched.nextRun = n ? n.toISOString() : null;
|
|
416
|
+
} catch {
|
|
417
|
+
sched.nextRun = null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
logLine(`[${new Date().toISOString()}] done internal ${sched.id} → ${res.ok ? 'success' : 'error'}`);
|
|
421
|
+
fired.push({ projectId: 'internal', scheduleId: sched.id, ok: res.ok, runResult: res });
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
logLine(`[${new Date().toISOString()}] failed internal ${sched.id}: ${err.message}`);
|
|
425
|
+
fired.push({ projectId: 'internal', scheduleId: sched.id, ok: false, runResult: { error: err.message } });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
303
429
|
if (skipped > 0) {
|
|
304
430
|
logLine(`[schedule] tick: ${skipped} skipped by budget pre-flight`);
|
|
305
431
|
}
|
|
@@ -40,6 +40,7 @@ import { V2EventBus } from './v2-event-bus.mjs';
|
|
|
40
40
|
import { loadOrCreateAuth, V2_DEFAULT_PORT } from './v2-auth-file.mjs';
|
|
41
41
|
import { createV2Router } from './routes-v2/index.mjs';
|
|
42
42
|
import { counter, gauge, render as renderMetrics } from './metrics.mjs';
|
|
43
|
+
import { warn } from './logger.mjs';
|
|
43
44
|
|
|
44
45
|
// v4.7.0 — Prometheus-style HTTP metrics. Bound to the server-wide
|
|
45
46
|
// registry; render() emits the text exposition format consumed by
|
|
@@ -190,6 +191,13 @@ export async function createServer({
|
|
|
190
191
|
// `unmatched` for 404s so the cardinality stays bounded. Hooked off
|
|
191
192
|
// `finish` so the final status code is captured even after error
|
|
192
193
|
// handlers run.
|
|
194
|
+
//
|
|
195
|
+
// v4.8.0 — When the status is 429, also emit a structured warning
|
|
196
|
+
// so operators can see when an IP is being throttled. The scope
|
|
197
|
+
// comes from the X-RateLimit-Scope header set by the per-route
|
|
198
|
+
// limiter middleware (chat / event); if no scope header is
|
|
199
|
+
// present, log the 429 with scope=unknown (it may be an upstream
|
|
200
|
+
// 429 proxied from the opencode plugin).
|
|
193
201
|
app.use((req, res, next) => {
|
|
194
202
|
res.on('finish', () => {
|
|
195
203
|
const route = req.route?.path ? `${req.baseUrl || ''}${req.route.path}` : 'unmatched';
|
|
@@ -198,6 +206,15 @@ export async function createServer({
|
|
|
198
206
|
route,
|
|
199
207
|
status: String(res.statusCode),
|
|
200
208
|
});
|
|
209
|
+
if (res.statusCode === 429) {
|
|
210
|
+
const scope = res.getHeader('X-RateLimit-Scope') || 'unknown';
|
|
211
|
+
warn('rate_limit_exceeded', {
|
|
212
|
+
ip: req.ip || (req.socket && req.socket.remoteAddress) || 'unknown',
|
|
213
|
+
scope,
|
|
214
|
+
method: req.method,
|
|
215
|
+
route,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
201
218
|
});
|
|
202
219
|
next();
|
|
203
220
|
});
|
|
@@ -715,6 +715,13 @@ function Shell() {
|
|
|
715
715
|
|
|
716
716
|
return (
|
|
717
717
|
<div className="app" data-layout={layout} data-active-tab={activeTab}>
|
|
718
|
+
{/* v4.8.0 — WCAG 2.2 AA: skip-to-main-content link. Hidden visually
|
|
719
|
+
until focused; clicking moves keyboard focus past the nav into
|
|
720
|
+
the active view. Screen readers and keyboard users can jump
|
|
721
|
+
past the topbar + sidebar without tabbing through every tab. */}
|
|
722
|
+
<a className="skip-to-main" href="#main-content">
|
|
723
|
+
Skip to main content
|
|
724
|
+
</a>
|
|
718
725
|
{showHeader && (
|
|
719
726
|
<Topbar
|
|
720
727
|
activeTab={activeTab}
|
|
@@ -769,7 +776,7 @@ function Shell() {
|
|
|
769
776
|
onTabChange={setActiveTab}
|
|
770
777
|
/>
|
|
771
778
|
)}
|
|
772
|
-
<main className="content">
|
|
779
|
+
<main className="content" id="main-content" tabIndex={-1}>
|
|
773
780
|
{bootError && (
|
|
774
781
|
<div className="boot-error">
|
|
775
782
|
<h2>Dashboard unavailable</h2>
|