@polderlabs/bizar 5.0.0 → 5.0.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/{icons-CFqu2M-c.js → icons-Bo0iH9EC.js} +166 -121
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
- package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
- package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js → mobile-DD-FZrTC.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js.map → mobile-DD-FZrTC.js.map} +1 -1
- package/bizar-dash/dist/index.html +4 -4
- package/bizar-dash/dist/mobile.html +3 -3
- package/bizar-dash/node_modules/.package-lock.json +6 -0
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/package-lock.json +6 -0
- package/bizar-dash/src/server/api.mjs +7 -0
- package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
- package/bizar-dash/src/server/memory-store.mjs +46 -0
- package/bizar-dash/src/server/providers-store.mjs +11 -4
- package/bizar-dash/src/server/routes/_shared.mjs +2 -2
- package/bizar-dash/src/server/routes/doctor.mjs +71 -0
- package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
- package/bizar-dash/src/server/routes/memory.mjs +3 -0
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
- package/bizar-dash/src/server/routes/schedules.mjs +55 -0
- package/bizar-dash/src/server/serve-info.mjs +172 -0
- package/bizar-dash/src/web/App.tsx +48 -11
- package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
- package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
- package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
- package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
- package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
- package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
- package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
- package/bizar-dash/src/web/components/Topbar.tsx +11 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
- package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
- package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
- package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
- package/bizar-dash/src/web/lib/types.ts +134 -0
- package/bizar-dash/src/web/styles/chat.css +8 -5
- package/bizar-dash/src/web/styles/main.css +77 -2
- package/bizar-dash/src/web/styles/settings.css +265 -0
- package/bizar-dash/src/web/views/Chat.tsx +15 -1
- package/bizar-dash/src/web/views/Doctor.tsx +317 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
- package/bizar-dash/src/web/views/Schedules.tsx +94 -12
- package/bizar-dash/src/web/views/Settings.tsx +76 -46
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
- package/bizar-dash/tests/autosave.test.tsx +276 -0
- package/bizar-dash/tests/chat-composer.test.tsx +140 -0
- package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
- package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
- package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
- package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
- package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
- package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
- package/bizar-dash/tests/minimax-models.test.mjs +123 -0
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
- package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
- package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
- package/bizar-dash/tests/settings-layout.test.tsx +129 -0
- package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
- package/bizar-dash/tests/settings-nav.test.tsx +126 -0
- package/cli/commands/dash.mjs +5 -1
- package/cli/commands/minimax.mjs +5 -2
- package/cli/tests/minimax-cli.test.mjs +79 -0
- package/config/agents/frigg.md +1 -1
- package/config/agents/heimdall.md +1 -1
- package/config/agents/mimir.md +1 -1
- package/config/agents/quick.md +1 -1
- package/config/agents/semble-search.md +1 -1
- package/config/agents/vor.md +1 -1
- package/config/opencode.json +28 -21
- package/config/opencode.json.template +8 -1
- package/package.json +1 -1
- package/plugins/bizar/index.ts +77 -0
- package/plugins/bizar/src/compaction.d.mts +48 -0
- package/plugins/bizar/src/compaction.mjs +192 -0
- package/plugins/bizar/tests/compaction.test.ts +264 -0
- package/templates/schedules/daily-backup.json +12 -0
- package/templates/schedules/daily-cleanup.json +12 -0
- package/templates/schedules/hourly-health-check.json +12 -0
- package/templates/schedules/webhook-on-push.json +13 -0
- package/templates/schedules/weekly-digest.json +13 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +0 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +0 -16
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +0 -1
- package/bizar-dash/dist/assets/main-ZAfGKENE.css +0 -1
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +0 -2
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +0 -1
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
|
@@ -131,8 +131,8 @@ export const DEFAULT_SETTINGS = {
|
|
|
131
131
|
},
|
|
132
132
|
systemLlm: {
|
|
133
133
|
enabled: true,
|
|
134
|
-
provider: '
|
|
135
|
-
model: '
|
|
134
|
+
provider: 'minimax',
|
|
135
|
+
model: 'minimax/MiniMax-M2.7',
|
|
136
136
|
},
|
|
137
137
|
// v4.5.0 — MiniMax Token Plan integration. `apiKey` is the
|
|
138
138
|
// user's Subscription Key from
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/doctor.mjs
|
|
3
|
+
*
|
|
4
|
+
* v6.0.0 — Doctor REST surface for the dashboard Doctor page.
|
|
5
|
+
*
|
|
6
|
+
* GET /api/doctor — full system snapshot (collectDiagnostics)
|
|
7
|
+
* GET /api/doctor/health — rolled-up status + per-group issues
|
|
8
|
+
* POST /api/doctor/check — run a single named check
|
|
9
|
+
*
|
|
10
|
+
* Mounted at /doctor by api.mjs. Existing /api/diagnostics/* routes
|
|
11
|
+
* (legacy v3) are untouched and keep using `diagnosticsStore.snapshot()`
|
|
12
|
+
* / `.health()`.
|
|
13
|
+
*/
|
|
14
|
+
import { Router } from 'express';
|
|
15
|
+
import { wrap } from './_shared.mjs';
|
|
16
|
+
import { collectDiagnostics, runCheck, health } from '../diagnostics-store.mjs';
|
|
17
|
+
import { info } from '../logger.mjs';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @returns {import('express').Router}
|
|
21
|
+
*/
|
|
22
|
+
export function createDoctorRouter() {
|
|
23
|
+
const router = Router();
|
|
24
|
+
|
|
25
|
+
// GET /api/doctor — full snapshot. Bounded by the underlying
|
|
26
|
+
// `collectDiagnostics()` (recentErrors is capped at 50, checks at
|
|
27
|
+
// ~12 entries, agent file list at directory size).
|
|
28
|
+
router.get('/', wrap(async (_req, res) => {
|
|
29
|
+
const snap = await collectDiagnostics();
|
|
30
|
+
info('doctor.snapshot', { status: snap.health.status, issues: snap.health.issues.length });
|
|
31
|
+
res.json(snap);
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// GET /api/doctor/health — rolled-up health. Cheap; the Doctor
|
|
35
|
+
// page polls this on every auto-refresh tick (30s) and only
|
|
36
|
+
// fetches the full snapshot when the user clicks "Refresh".
|
|
37
|
+
router.get('/health', wrap(async (_req, res) => {
|
|
38
|
+
const result = await health();
|
|
39
|
+
res.json(result);
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// POST /api/doctor/check — run a single named check.
|
|
43
|
+
//
|
|
44
|
+
// Body: `{ checkName: string }`.
|
|
45
|
+
//
|
|
46
|
+
// Returns the single check on success, or `{ status: 'fail',
|
|
47
|
+
// message: 'unknown check', error: '...' }` with HTTP 404 when
|
|
48
|
+
// the name doesn't match any registered check. The dashboard
|
|
49
|
+
// surfaces the message inline so the operator can fix the
|
|
50
|
+
// underlying issue without leaving the Doctor page.
|
|
51
|
+
router.post('/check', wrap(async (req, res) => {
|
|
52
|
+
const checkName = String(req.body?.checkName || '').trim();
|
|
53
|
+
if (!checkName) {
|
|
54
|
+
res.status(400).json({
|
|
55
|
+
status: 'fail',
|
|
56
|
+
name: '',
|
|
57
|
+
message: 'checkName required',
|
|
58
|
+
error: 'POST body must include { checkName: string }',
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const result = await runCheck(checkName);
|
|
63
|
+
if (result.status === 'fail' && result.message === 'unknown check') {
|
|
64
|
+
res.status(404).json(result);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
res.json(result);
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
return router;
|
|
71
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/server/routes/env-vars.mjs
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* v2 — Bizar env vars stored in ~/.config/bizar/env.json (mode 0600).
|
|
5
5
|
*
|
|
6
6
|
* Endpoints:
|
|
7
7
|
* GET /api/env-vars — list all (masked values)
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* PUT /api/env-vars/:name — update {value}
|
|
10
10
|
* DELETE /api/env-vars/:name — remove + delete from process.env
|
|
11
11
|
* POST /api/env-vars/:name/test — {referenced, inProcessEnv}
|
|
12
|
+
* POST /api/env-vars/bulk-import — {ok, imported, skipped, errors[]}
|
|
13
|
+
* GET /api/env-vars/export — text/plain .env format
|
|
14
|
+
* GET /api/env-vars/grouped — { "BIZAR_*": [...], "PROVIDER_*": [...] }
|
|
12
15
|
*
|
|
13
16
|
* Name must match /^BIZAR_[A-Z0-9_]+$/.
|
|
14
17
|
* On startup, loadEnvJson() sets process.env[name] = value for every entry.
|
|
@@ -161,5 +164,68 @@ export function createEnvVarsRouter() {
|
|
|
161
164
|
res.json({ referenced: inProcessEnv, inProcessEnv });
|
|
162
165
|
}));
|
|
163
166
|
|
|
167
|
+
// POST /api/env-vars/bulk-import — parse KEY=value lines
|
|
168
|
+
router.post('/env-vars/bulk-import', wrap(async (req, res) => {
|
|
169
|
+
const { envContent } = req.body || {};
|
|
170
|
+
if (typeof envContent !== 'string') {
|
|
171
|
+
res.status(400).json({ error: 'envContent_required', message: 'envContent string is required' });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const lines = envContent.split('\n');
|
|
175
|
+
const store = readStore();
|
|
176
|
+
let imported = 0;
|
|
177
|
+
let skipped = 0;
|
|
178
|
+
const errors = [];
|
|
179
|
+
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
const trimmed = line.trim();
|
|
182
|
+
if (!trimmed || trimmed.startsWith('#')) { skipped++; continue; }
|
|
183
|
+
const eqIdx = trimmed.indexOf('=');
|
|
184
|
+
if (eqIdx < 1) { errors.push(`Invalid line (no '='): ${trimmed.slice(0, 40)}`); skipped++; continue; }
|
|
185
|
+
const rawName = trimmed.slice(0, eqIdx).trim();
|
|
186
|
+
const value = trimmed.slice(eqIdx + 1);
|
|
187
|
+
if (!NAME_RE.test(rawName)) { errors.push(`Invalid name: ${rawName}`); skipped++; continue; }
|
|
188
|
+
const name = rawName.toUpperCase();
|
|
189
|
+
if (store[name]) { errors.push(`Already exists (skipping): ${name}`); skipped++; continue; }
|
|
190
|
+
const entry = { value, createdAt: new Date().toISOString(), source: 'bulk-import' };
|
|
191
|
+
store[name] = entry;
|
|
192
|
+
process.env[name] = value;
|
|
193
|
+
imported++;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (imported > 0) writeStore(store);
|
|
197
|
+
res.json({ ok: true, imported, skipped, errors });
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
// GET /api/env-vars/export — return .env format
|
|
201
|
+
router.get('/env-vars/export', wrap(async (_req, res) => {
|
|
202
|
+
const store = readStore();
|
|
203
|
+
const lines = [];
|
|
204
|
+
for (const [name, entry] of Object.entries(store)) {
|
|
205
|
+
lines.push(`${name}=${entry.value}`);
|
|
206
|
+
}
|
|
207
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
208
|
+
res.setHeader('Content-Disposition', 'attachment; filename=".env"');
|
|
209
|
+
res.send(lines.join('\n'));
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
// GET /api/env-vars/grouped — return vars grouped by prefix
|
|
213
|
+
router.get('/env-vars/grouped', wrap(async (_req, res) => {
|
|
214
|
+
const store = readStore();
|
|
215
|
+
const groups = {};
|
|
216
|
+
for (const [name, entry] of Object.entries(store)) {
|
|
217
|
+
// Extract second-level prefix: BIZAR_EXT_A -> EXT_, BIZAR_PROVIDER_KEY -> PROVIDER_
|
|
218
|
+
const m = name.match(/^BIZAR_([A-Z0-9]+_)/);
|
|
219
|
+
const prefix = m ? m[1] : name;
|
|
220
|
+
(groups[prefix] ??= []).push({
|
|
221
|
+
name,
|
|
222
|
+
value: mask(entry.value),
|
|
223
|
+
createdAt: entry.createdAt,
|
|
224
|
+
source: entry.source,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
res.json(groups);
|
|
228
|
+
}));
|
|
229
|
+
|
|
164
230
|
return router;
|
|
165
231
|
}
|
|
@@ -48,6 +48,9 @@ function redactLightRAGConfig(config) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export function createMemoryRouter({ projectRoot }) {
|
|
51
|
+
// Auto-create the default vault and git-init if needed — idempotent.
|
|
52
|
+
memoryStore.ensureVaultExists();
|
|
53
|
+
|
|
51
54
|
const router = Router();
|
|
52
55
|
|
|
53
56
|
// GET /memory/status
|
|
@@ -31,24 +31,34 @@
|
|
|
31
31
|
* `error` event and end the stream rather than risk file-descriptor
|
|
32
32
|
* exhaustion when many tabs are open.
|
|
33
33
|
*
|
|
34
|
-
* The directory resolver
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* The directory resolver lives in `serve-info.mjs` as
|
|
35
|
+
* `resolveSessionDirectory(sessionId, serveInfo)` and is shared with
|
|
36
|
+
* `routes/opencode-sessions.mjs`. It probes the recorded worktree
|
|
37
|
+
* first (cheap), then falls back to listing every session and
|
|
38
|
+
* matching on id. If neither yields a directory we 503 with
|
|
39
|
+
* `directory_unknown`.
|
|
40
|
+
*
|
|
41
|
+
* v5.0.0 — bug #4 fix: structured error envelopes with `cause`,
|
|
42
|
+
* `status`, and `suggestion`; structured `logger.error` on every
|
|
43
|
+
* upstream failure for diagnostics.
|
|
38
44
|
*/
|
|
39
45
|
|
|
40
46
|
import { Router } from 'express';
|
|
41
47
|
import {
|
|
42
48
|
readServeInfo,
|
|
43
49
|
listOpencodeMessages,
|
|
44
|
-
|
|
50
|
+
resolveSessionDirectory,
|
|
45
51
|
sendOpencodePrompt,
|
|
46
52
|
unwrapOpencodeSseEvent,
|
|
47
53
|
buildAuthHeader,
|
|
48
54
|
extractContentFromOpencodeMessage,
|
|
49
55
|
} from '../serve-info.mjs';
|
|
56
|
+
import { child as loggerChild } from '../logger.mjs';
|
|
50
57
|
import { wrap } from './_shared.mjs';
|
|
51
58
|
|
|
59
|
+
/** Structured logger scoped to this route — emits `{module:'opencode-session-detail', ...}` on every line. */
|
|
60
|
+
const logger = loggerChild({ module: 'opencode-session-detail' });
|
|
61
|
+
|
|
52
62
|
/** Maximum number of concurrent SSE subscribers on this dashboard process. */
|
|
53
63
|
const MAX_SSE_SUBSCRIBERS = 50;
|
|
54
64
|
/** Read the heartbeat interval on demand so tests can shrink it via
|
|
@@ -62,39 +72,6 @@ const LIST_MESSAGES_TIMEOUT_MS = 8_000;
|
|
|
62
72
|
/** Default timeout for the upstream prompt endpoint. */
|
|
63
73
|
const SEND_PROMPT_TIMEOUT_MS = 12_000;
|
|
64
74
|
|
|
65
|
-
/**
|
|
66
|
-
* Resolve the opencode `directory` query parameter for a session.
|
|
67
|
-
*
|
|
68
|
-
* Tries (in order):
|
|
69
|
-
* 1. The session's own `location.directory` from `listOpencodeSessions`.
|
|
70
|
-
* 2. `info.worktree` (the plugin's recorded cwd at startup).
|
|
71
|
-
*
|
|
72
|
-
* Returns `null` when neither is known — callers should 503 in that
|
|
73
|
-
* case so the UI can show "directory unknown" rather than sending the
|
|
74
|
-
* prompt with a blank directory (which the opencode API would 400).
|
|
75
|
-
*
|
|
76
|
-
* @param {ReturnType<typeof readServeInfo>} info
|
|
77
|
-
* @param {string} sessionId
|
|
78
|
-
* @returns {Promise<string|null>}
|
|
79
|
-
*/
|
|
80
|
-
async function resolveSessionDirectory(info, sessionId) {
|
|
81
|
-
if (!info) return null;
|
|
82
|
-
try {
|
|
83
|
-
const sessions = await listOpencodeSessions(info, 5_000);
|
|
84
|
-
if (Array.isArray(sessions)) {
|
|
85
|
-
const entry = sessions.find((s) => s && s.id === sessionId);
|
|
86
|
-
const dir = entry?.location?.directory;
|
|
87
|
-
if (typeof dir === 'string' && dir.length > 0) return dir;
|
|
88
|
-
}
|
|
89
|
-
} catch {
|
|
90
|
-
/* fall through to worktree */
|
|
91
|
-
}
|
|
92
|
-
if (typeof info.worktree === 'string' && info.worktree.length > 0) {
|
|
93
|
-
return info.worktree;
|
|
94
|
-
}
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
75
|
/**
|
|
99
76
|
* Normalize an opencode message into the dashboard's chat shape:
|
|
100
77
|
* { id, role, content, ts }
|
|
@@ -145,21 +122,44 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
145
122
|
return res.status(503).json({
|
|
146
123
|
error: 'plugin_offline',
|
|
147
124
|
message: 'opencode plugin is not running',
|
|
125
|
+
suggestion: 'Run `bizar doctor` for diagnostics, or start opencode with `opencode serve`',
|
|
148
126
|
});
|
|
149
127
|
}
|
|
150
|
-
const directory = await resolveSessionDirectory(
|
|
128
|
+
const directory = await resolveSessionDirectory(sessionId, info);
|
|
151
129
|
if (!directory) {
|
|
152
130
|
return res.status(503).json({
|
|
153
131
|
error: 'directory_unknown',
|
|
154
132
|
message:
|
|
155
133
|
'Cannot determine the opencode session directory; ensure the opencode plugin is running with serve.json containing worktree.',
|
|
134
|
+
suggestion: 'Create a new session or restart opencode serve.',
|
|
156
135
|
});
|
|
157
136
|
}
|
|
158
137
|
const result = await listOpencodeMessages(info, sessionId, directory, LIST_MESSAGES_TIMEOUT_MS);
|
|
159
138
|
if (!result?.ok) {
|
|
139
|
+
const errMsg = result?.error || 'unknown error from opencode serve';
|
|
140
|
+
// v5.0.0 — bug #4: prefer the structured `cause` field if the
|
|
141
|
+
// helper surfaced one (it carries `ECONNREFUSED` / `UND_ERR_*`
|
|
142
|
+
// from the underlying fetch); fall back to substring inference
|
|
143
|
+
// for older error message shapes.
|
|
144
|
+
let cause = result?.cause || 'unknown';
|
|
145
|
+
if (cause === 'unknown') {
|
|
146
|
+
if (errMsg.includes('timed out')) cause = 'timeout';
|
|
147
|
+
else if (errMsg.includes('network error') || errMsg.includes('fetch failed')) cause = 'network';
|
|
148
|
+
}
|
|
149
|
+
logger.error('opencode listMessages failed', {
|
|
150
|
+
sessionId,
|
|
151
|
+
worktree: directory,
|
|
152
|
+
servePort: info.port,
|
|
153
|
+
status: result?.status ?? null,
|
|
154
|
+
cause,
|
|
155
|
+
err: errMsg,
|
|
156
|
+
});
|
|
160
157
|
return res.status(502).json({
|
|
161
158
|
error: 'opencode_error',
|
|
162
|
-
message:
|
|
159
|
+
message: errMsg,
|
|
160
|
+
cause,
|
|
161
|
+
status: result?.status ?? undefined,
|
|
162
|
+
suggestion: 'Check that the opencode serve child is running. Try `bizar doctor` or restart the plugin.',
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
165
|
const messages = Array.isArray(result.messages) ? result.messages.map(toChatMessage) : [];
|
|
@@ -191,14 +191,16 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
191
191
|
return res.status(503).json({
|
|
192
192
|
error: 'plugin_offline',
|
|
193
193
|
message: 'opencode plugin is not running',
|
|
194
|
+
suggestion: 'Run `bizar doctor` for diagnostics, or start opencode with `opencode serve`',
|
|
194
195
|
});
|
|
195
196
|
}
|
|
196
|
-
const directory = await resolveSessionDirectory(
|
|
197
|
+
const directory = await resolveSessionDirectory(sessionId, info);
|
|
197
198
|
if (!directory) {
|
|
198
199
|
return res.status(503).json({
|
|
199
200
|
error: 'directory_unknown',
|
|
200
201
|
message:
|
|
201
202
|
'Cannot determine the opencode session directory; ensure the opencode plugin is running with serve.json containing worktree.',
|
|
203
|
+
suggestion: 'Create a new session or restart opencode serve.',
|
|
202
204
|
});
|
|
203
205
|
}
|
|
204
206
|
// Synthesize a unique messageID so the client can correlate the
|
|
@@ -211,9 +213,26 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
211
213
|
SEND_PROMPT_TIMEOUT_MS,
|
|
212
214
|
);
|
|
213
215
|
if (!result?.ok) {
|
|
216
|
+
const errMsg = result?.error || 'unknown error from opencode serve';
|
|
217
|
+
let cause = result?.cause || 'unknown';
|
|
218
|
+
if (cause === 'unknown') {
|
|
219
|
+
if (errMsg.includes('timed out')) cause = 'timeout';
|
|
220
|
+
else if (errMsg.includes('network error') || errMsg.includes('fetch failed')) cause = 'network';
|
|
221
|
+
}
|
|
222
|
+
logger.error('opencode sendPrompt failed', {
|
|
223
|
+
sessionId,
|
|
224
|
+
worktree: directory,
|
|
225
|
+
servePort: info.port,
|
|
226
|
+
status: result?.status ?? null,
|
|
227
|
+
cause,
|
|
228
|
+
err: errMsg,
|
|
229
|
+
});
|
|
214
230
|
return res.status(502).json({
|
|
215
231
|
error: 'opencode_error',
|
|
216
|
-
message:
|
|
232
|
+
message: errMsg,
|
|
233
|
+
cause,
|
|
234
|
+
status: result?.status ?? undefined,
|
|
235
|
+
suggestion: 'Check that the opencode serve child is running. Try `bizar doctor` or restart the plugin.',
|
|
217
236
|
});
|
|
218
237
|
}
|
|
219
238
|
return res.json({ ok: true, messageId: result.messageId });
|
|
@@ -14,10 +14,31 @@
|
|
|
14
14
|
* rest of the schedule.
|
|
15
15
|
*/
|
|
16
16
|
import { Router } from 'express';
|
|
17
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
17
20
|
import { schedulesStore } from '../schedules-store.mjs';
|
|
18
21
|
import { schedulesRunner } from '../schedules-runner.mjs';
|
|
19
22
|
import { readActiveProjectId, wrap } from './_shared.mjs';
|
|
20
23
|
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const TEMPLATES_DIR = join(__dirname, '..', '..', '..', '..', 'templates', 'schedules');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load all schedule templates from templates/schedules/.
|
|
29
|
+
* Each template gets `source: 'template'` and `templateFile` added.
|
|
30
|
+
* Missing directory returns an empty array.
|
|
31
|
+
*/
|
|
32
|
+
function loadTemplates() {
|
|
33
|
+
if (!existsSync(TEMPLATES_DIR)) return [];
|
|
34
|
+
return readdirSync(TEMPLATES_DIR)
|
|
35
|
+
.filter((f) => f.endsWith('.json'))
|
|
36
|
+
.map((f) => {
|
|
37
|
+
const content = JSON.parse(readFileSync(join(TEMPLATES_DIR, f), 'utf8'));
|
|
38
|
+
return { ...content, source: 'template', templateFile: f };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
/**
|
|
22
43
|
* @param {object} deps
|
|
23
44
|
* @param {Function} deps.broadcast
|
|
@@ -100,5 +121,39 @@ export function createSchedulesRouter({ broadcast }) {
|
|
|
100
121
|
res.status(204).end();
|
|
101
122
|
}));
|
|
102
123
|
|
|
124
|
+
// GET /api/schedules/templates → { templates: [...] }
|
|
125
|
+
router.get('/schedules/templates', wrap(async (_req, res) => {
|
|
126
|
+
res.json({ templates: loadTemplates() });
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
// POST /api/schedules/from-template → creates a schedule from a template
|
|
130
|
+
router.post('/schedules/from-template', wrap(async (req, res) => {
|
|
131
|
+
const projectId = req.body?.projectId || readActiveProjectId() || 'default';
|
|
132
|
+
const { templateId, name: customName } = req.body || {};
|
|
133
|
+
if (!templateId) {
|
|
134
|
+
res.status(400).json({ error: 'templateId is required' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const templates = loadTemplates();
|
|
138
|
+
const template = templates.find((t) => t.id === templateId);
|
|
139
|
+
if (!template) {
|
|
140
|
+
res.status(404).json({ error: 'template not found' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// Build a schedule payload from the template, allowing name override
|
|
144
|
+
const schedulePayload = {
|
|
145
|
+
name: customName || template.name,
|
|
146
|
+
type: template.type,
|
|
147
|
+
schedule: template.schedule,
|
|
148
|
+
timezone: template.timezone || 'UTC',
|
|
149
|
+
action: template.action,
|
|
150
|
+
budgetCheck: template.budgetCheck,
|
|
151
|
+
enabled: true,
|
|
152
|
+
};
|
|
153
|
+
const sched = schedulesStore.add(projectId, schedulePayload);
|
|
154
|
+
broadcast({ type: 'schedules:change' });
|
|
155
|
+
res.status(201).json(sched);
|
|
156
|
+
}));
|
|
157
|
+
|
|
103
158
|
return router;
|
|
104
159
|
}
|
|
@@ -56,6 +56,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
56
56
|
import { join } from 'node:path';
|
|
57
57
|
import { homedir } from 'node:os';
|
|
58
58
|
import { createConnection, isIP } from 'node:net';
|
|
59
|
+
import { warn as loggerWarn, error as loggerError } from './logger.mjs';
|
|
59
60
|
|
|
60
61
|
const HOME = homedir();
|
|
61
62
|
|
|
@@ -321,6 +322,147 @@ export async function listOpencodeSessions(info, timeoutMs = 5000) {
|
|
|
321
322
|
|
|
322
323
|
export const SERVE_INFO_FILE_PATHS = DEFAULT_SERVE_INFO_FILES;
|
|
323
324
|
|
|
325
|
+
// ── v5.0.0 — bug #4 — directory resolver (shared between route modules) ──
|
|
326
|
+
//
|
|
327
|
+
// Both `routes/opencode-sessions.mjs` and `routes/opencode-session-detail.mjs`
|
|
328
|
+
// need the same logic: given a sessionId and the plugin's serve-info, figure
|
|
329
|
+
// out which `directory` (worktree) to pass as a `?directory=…` query param
|
|
330
|
+
// to opencode's HTTP API. We previously had two copies of this resolver
|
|
331
|
+
// inline in each route; now there's one in serve-info.mjs so a fix here
|
|
332
|
+
// reaches both routes.
|
|
333
|
+
//
|
|
334
|
+
// Strategy (per issue #4 brief):
|
|
335
|
+
// 1. If serve.json has a `worktree` AND a fast probe (`GET
|
|
336
|
+
// /api/session/{id}?directory=<worktree>`) succeeds (HTTP 200), use
|
|
337
|
+
// the worktree — no need to list every opencode session.
|
|
338
|
+
// 2. Otherwise, list every session via `GET /api/session` and find the
|
|
339
|
+
// one whose id matches. Use its `location.directory` (or the legacy
|
|
340
|
+
// `worktree` field, if present) as the directory.
|
|
341
|
+
// 3. If neither yields a directory, return null — callers should 503
|
|
342
|
+
// so the UI can show "directory unknown" rather than sending the
|
|
343
|
+
// request with a blank `?directory=` (opencode 400s on that).
|
|
344
|
+
//
|
|
345
|
+
// `worktreeHasSession` is a small `GET /api/session/{id}?directory=...`
|
|
346
|
+
// probe used as a fast-path so we don't pay the cost of listing every
|
|
347
|
+
// session on every listMessages call. The endpoint returns 200 when the
|
|
348
|
+
// session exists in that worktree, 404 when it doesn't. We treat 2xx
|
|
349
|
+
// other than 200 as success (opencode has shipped both `200` and `204`
|
|
350
|
+
// on this endpoint across versions).
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Lightweight probe — does `sessionId` exist in `worktree`?
|
|
354
|
+
*
|
|
355
|
+
* Implemented as `GET /api/session/{id}?directory={worktree}`. Returns
|
|
356
|
+
* `true` on 2xx, `false` on 404, `null` on network / auth / other
|
|
357
|
+
* errors (the caller should fall back to the slower list path and
|
|
358
|
+
* trust THAT result).
|
|
359
|
+
*
|
|
360
|
+
* Never throws.
|
|
361
|
+
*
|
|
362
|
+
* @param {ServeInfo} info
|
|
363
|
+
* @param {string} sessionId
|
|
364
|
+
* @param {string} worktree
|
|
365
|
+
* @param {number} [timeoutMs]
|
|
366
|
+
* @returns {Promise<boolean|null>}
|
|
367
|
+
*/
|
|
368
|
+
async function worktreeHasSession(info, sessionId, worktree, timeoutMs = 2_500) {
|
|
369
|
+
if (!info || !sessionId || !worktree) return null;
|
|
370
|
+
const url = `${info.baseUrl}/api/session/${encodeURIComponent(sessionId)}?directory=${encodeURIComponent(worktree)}`;
|
|
371
|
+
const ac = new AbortController();
|
|
372
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
373
|
+
try {
|
|
374
|
+
const res = await fetch(url, {
|
|
375
|
+
method: 'GET',
|
|
376
|
+
headers: {
|
|
377
|
+
Authorization: buildAuthHeader(info),
|
|
378
|
+
Accept: 'application/json',
|
|
379
|
+
},
|
|
380
|
+
signal: ac.signal,
|
|
381
|
+
});
|
|
382
|
+
if (res.status >= 200 && res.status < 300) return true;
|
|
383
|
+
if (res.status === 404) return false;
|
|
384
|
+
// 401/403/5xx → don't trust the result, let the fallback path decide.
|
|
385
|
+
return null;
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
} finally {
|
|
389
|
+
clearTimeout(timer);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Resolve the opencode `directory` (worktree) for a session.
|
|
395
|
+
*
|
|
396
|
+
* Per the brief:
|
|
397
|
+
* 1. If we have a worktree AND a fast probe confirms the session
|
|
398
|
+
* lives there, return it.
|
|
399
|
+
* 2. Otherwise, list every session and find the matching one; use
|
|
400
|
+
* its `location.directory` (or legacy `worktree`).
|
|
401
|
+
* 3. Return null if neither yields a directory.
|
|
402
|
+
*
|
|
403
|
+
* Signature is `(sessionId, serveInfo)` (positional, sessionId first)
|
|
404
|
+
* to match the brief. Callers that only have `serveInfo` can pass
|
|
405
|
+
* `null` for `sessionId` and the function will fall through to the
|
|
406
|
+
* list path.
|
|
407
|
+
*
|
|
408
|
+
* Never throws.
|
|
409
|
+
*
|
|
410
|
+
* @param {string|null|undefined} sessionId
|
|
411
|
+
* @param {ServeInfo|null|undefined} serveInfo
|
|
412
|
+
* @returns {Promise<string|null>}
|
|
413
|
+
*/
|
|
414
|
+
export async function resolveSessionDirectory(sessionId, serveInfo) {
|
|
415
|
+
if (!serveInfo) return null;
|
|
416
|
+
|
|
417
|
+
// Fast path: probe the recorded worktree first. If the session lives
|
|
418
|
+
// there, we save the round-trip to /api/session.
|
|
419
|
+
if (typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
|
|
420
|
+
const probe = await worktreeHasSession(serveInfo, sessionId || '', serveInfo.worktree);
|
|
421
|
+
if (probe === true) return serveInfo.worktree;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Slower fallback: list every session and find the match.
|
|
425
|
+
try {
|
|
426
|
+
const sessions = await listOpencodeSessions(serveInfo, 5_000);
|
|
427
|
+
if (Array.isArray(sessions)) {
|
|
428
|
+
const entry = sessionId
|
|
429
|
+
? sessions.find(
|
|
430
|
+
(s) =>
|
|
431
|
+
s && (s.id === sessionId || (typeof s.sessionId === 'string' && s.sessionId === sessionId)),
|
|
432
|
+
)
|
|
433
|
+
: null;
|
|
434
|
+
if (entry) {
|
|
435
|
+
const dir = entry?.location?.directory;
|
|
436
|
+
if (typeof dir === 'string' && dir.length > 0) return dir;
|
|
437
|
+
// Defensive: some opencode builds store the worktree on the
|
|
438
|
+
// session at the top level instead of under `location`.
|
|
439
|
+
const legacy = entry?.worktree;
|
|
440
|
+
if (typeof legacy === 'string' && legacy.length > 0) return legacy;
|
|
441
|
+
}
|
|
442
|
+
// No matching session, but we have a worktree from serve.json —
|
|
443
|
+
// last-chance fallback. This mirrors the pre-v5.0.0 inline
|
|
444
|
+
// resolver behaviour so an unmatched session still routes to
|
|
445
|
+
// the plugin's recorded cwd rather than 503'ing.
|
|
446
|
+
if (!entry && typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
|
|
447
|
+
return serveInfo.worktree;
|
|
448
|
+
}
|
|
449
|
+
} else if (typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
|
|
450
|
+
// listOpencodeSessions returned null (serve offline / auth fail).
|
|
451
|
+
// Fall back to the worktree so we at least try the same
|
|
452
|
+
// directory the plugin recorded — the upstream 404/502 will
|
|
453
|
+
// surface a clearer error than a 503 directory_unknown.
|
|
454
|
+
return serveInfo.worktree;
|
|
455
|
+
}
|
|
456
|
+
} catch (err) {
|
|
457
|
+
loggerWarn('resolveSessionDirectory: listOpencodeSessions failed', {
|
|
458
|
+
sessionId: sessionId || null,
|
|
459
|
+
err: err instanceof Error ? err.message : String(err),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
324
466
|
// ── v3.5.4 (bug: dispatch stuck) — spawn helpers ─────────────────────────
|
|
325
467
|
//
|
|
326
468
|
// The dashboard's task delegator used to shell out to
|
|
@@ -473,8 +615,21 @@ export async function sendOpencodePrompt(info, opts, directory, timeoutMs = DEFA
|
|
|
473
615
|
} catch (err) {
|
|
474
616
|
const msg = err instanceof Error ? err.message : String(err);
|
|
475
617
|
const isAbort = err instanceof Error && err.name === 'AbortError';
|
|
618
|
+
let cause = 'unknown';
|
|
619
|
+
if (isAbort) cause = 'timeout';
|
|
620
|
+
else if (err && typeof err === 'object') {
|
|
621
|
+
const code = /** @type {any} */ (err).code;
|
|
622
|
+
if (typeof code === 'string' && code.length > 0) {
|
|
623
|
+
cause = code.startsWith('ECONN') || code.startsWith('UND_ERR') || code === 'ENOTFOUND'
|
|
624
|
+
? 'network'
|
|
625
|
+
: code;
|
|
626
|
+
} else {
|
|
627
|
+
cause = 'network';
|
|
628
|
+
}
|
|
629
|
+
}
|
|
476
630
|
return {
|
|
477
631
|
ok: false,
|
|
632
|
+
cause,
|
|
478
633
|
error: isAbort ? `sendPrompt timed out after ${timeoutMs}ms` : `sendPrompt network error: ${msg}`,
|
|
479
634
|
};
|
|
480
635
|
} finally {
|
|
@@ -543,8 +698,25 @@ export async function listOpencodeMessages(info, sessionId, directory, timeoutMs
|
|
|
543
698
|
} catch (err) {
|
|
544
699
|
const msg = err instanceof Error ? err.message : String(err);
|
|
545
700
|
const isAbort = err instanceof Error && err.name === 'AbortError';
|
|
701
|
+
// v5.0.0 — bug #4: surface a structured `cause` to the route so it
|
|
702
|
+
// can render a useful suggestion without substring-sniffing the
|
|
703
|
+
// error message. Prefers the underlying network error code
|
|
704
|
+
// (ECONNREFUSED, UND_ERR_SOCKET, …) when present.
|
|
705
|
+
let cause = 'unknown';
|
|
706
|
+
if (isAbort) cause = 'timeout';
|
|
707
|
+
else if (err && typeof err === 'object') {
|
|
708
|
+
const code = /** @type {any} */ (err).code;
|
|
709
|
+
if (typeof code === 'string' && code.length > 0) {
|
|
710
|
+
cause = code.startsWith('ECONN') || code.startsWith('UND_ERR') || code === 'ENOTFOUND'
|
|
711
|
+
? 'network'
|
|
712
|
+
: code;
|
|
713
|
+
} else {
|
|
714
|
+
cause = 'network';
|
|
715
|
+
}
|
|
716
|
+
}
|
|
546
717
|
return {
|
|
547
718
|
ok: false,
|
|
719
|
+
cause,
|
|
548
720
|
error: isAbort ? `listMessages timed out after ${timeoutMs}ms` : `listMessages network error: ${msg}`,
|
|
549
721
|
};
|
|
550
722
|
} finally {
|