@polderlabs/bizar-dash 3.0.0
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/dist/assets/index-B5X9g8B4.css +1 -0
- package/dist/assets/index-LqQuSp9d.js +388 -0
- package/dist/assets/index-LqQuSp9d.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +67 -0
- package/src/cli.mjs +228 -0
- package/src/server/agents-store.mjs +190 -0
- package/src/server/api.mjs +913 -0
- package/src/server/browser.mjs +40 -0
- package/src/server/diagnostics-store.mjs +138 -0
- package/src/server/mods-loader.mjs +361 -0
- package/src/server/projects-store.mjs +198 -0
- package/src/server/providers-store.mjs +183 -0
- package/src/server/schedules-runner.mjs +150 -0
- package/src/server/schedules-store.mjs +233 -0
- package/src/server/search-store.mjs +120 -0
- package/src/server/server.mjs +388 -0
- package/src/server/state.mjs +357 -0
- package/src/server/tailscale-store.mjs +113 -0
- package/src/server/tasks-store.mjs +275 -0
- package/src/server/tui.mjs +844 -0
- package/src/server/watcher.mjs +81 -0
- package/src/web/App.tsx +316 -0
- package/src/web/components/Button.tsx +55 -0
- package/src/web/components/Card.tsx +40 -0
- package/src/web/components/EmptyState.tsx +30 -0
- package/src/web/components/Modal.tsx +137 -0
- package/src/web/components/SearchModal.tsx +185 -0
- package/src/web/components/Spinner.tsx +19 -0
- package/src/web/components/StatusBadge.tsx +25 -0
- package/src/web/components/Tag.tsx +28 -0
- package/src/web/components/Toast.tsx +142 -0
- package/src/web/components/Topbar.tsx +203 -0
- package/src/web/index.html +17 -0
- package/src/web/lib/api.ts +71 -0
- package/src/web/lib/markdown.tsx +59 -0
- package/src/web/lib/types.ts +388 -0
- package/src/web/lib/utils.ts +79 -0
- package/src/web/lib/ws.ts +132 -0
- package/src/web/main.tsx +12 -0
- package/src/web/styles/main.css +3148 -0
- package/src/web/views/Agents.tsx +406 -0
- package/src/web/views/Chat.tsx +527 -0
- package/src/web/views/Config.tsx +683 -0
- package/src/web/views/Mods.tsx +350 -0
- package/src/web/views/Overview.tsx +350 -0
- package/src/web/views/Plans.tsx +667 -0
- package/src/web/views/Schedules.tsx +299 -0
- package/src/web/views/Settings.tsx +571 -0
- package/src/web/views/Tasks.tsx +761 -0
- package/templates/mod/FORMAT.md +76 -0
- package/templates/mod/hello-mod/README.md +19 -0
- package/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/templates/mod/hello-mod/commands/hello.md +6 -0
- package/templates/mod/hello-mod/mod.json +20 -0
- package/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/schedules-store.mjs
|
|
3
|
+
*
|
|
4
|
+
* v3.0.0 — Schedules registry.
|
|
5
|
+
*
|
|
6
|
+
* Each project has its own schedules.json at
|
|
7
|
+
* ~/.config/opencode/projects/<id>/schedules.json
|
|
8
|
+
*
|
|
9
|
+
* A schedule is a recurring task: { name, type, schedule, action, enabled, ... }.
|
|
10
|
+
* Supported types:
|
|
11
|
+
* - interval ("30m", "2h", "1d")
|
|
12
|
+
* - cron ("0 0 * * *") — best-effort: we evaluate on each tick
|
|
13
|
+
* to detect the next minute boundary
|
|
14
|
+
* - once (ISO timestamp)
|
|
15
|
+
*
|
|
16
|
+
* The dashboard reads / writes schedules here. The bizar service daemon
|
|
17
|
+
* (cli/service.mjs) calls evaluateAndRun() on a tick to fire due actions.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
import { projectsStore } from './projects-store.mjs';
|
|
28
|
+
|
|
29
|
+
function safeReadJSON(file, fallback = null) {
|
|
30
|
+
try {
|
|
31
|
+
if (!existsSync(file)) return fallback;
|
|
32
|
+
const text = readFileSync(file, 'utf8');
|
|
33
|
+
if (!text.trim()) return fallback;
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
} catch {
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function genId() {
|
|
41
|
+
return 'sched_' + randomBytes(5).toString('hex').slice(0, 10);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse "30m" / "2h" / "1d" / "45s" → milliseconds. */
|
|
45
|
+
export function parseInterval(s) {
|
|
46
|
+
if (typeof s !== 'string') return null;
|
|
47
|
+
const m = /^(\d+)\s*(s|sec|second|m|min|minute|h|hr|hour|d|day)$/i.exec(s.trim());
|
|
48
|
+
if (!m) return null;
|
|
49
|
+
const n = parseInt(m[1], 10);
|
|
50
|
+
const unit = m[2].toLowerCase();
|
|
51
|
+
if (unit.startsWith('s')) return n * 1000;
|
|
52
|
+
if (unit.startsWith('m')) return n * 60 * 1000;
|
|
53
|
+
if (unit.startsWith('h')) return n * 60 * 60 * 1000;
|
|
54
|
+
if (unit.startsWith('d')) return n * 24 * 60 * 60 * 1000;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Compute the next firing time for a schedule. Returns ISO string or null. */
|
|
59
|
+
export function computeNextRun(schedule, fromMs = Date.now()) {
|
|
60
|
+
const type = schedule.type;
|
|
61
|
+
if (type === 'interval') {
|
|
62
|
+
const ms = parseInterval(schedule.schedule);
|
|
63
|
+
if (!ms) return null;
|
|
64
|
+
const last = schedule.lastRun ? new Date(schedule.lastRun).getTime() : fromMs;
|
|
65
|
+
return new Date(last + ms).toISOString();
|
|
66
|
+
}
|
|
67
|
+
if (type === 'once') {
|
|
68
|
+
if (schedule.lastRun) return null; // already fired
|
|
69
|
+
const when = new Date(schedule.schedule).getTime();
|
|
70
|
+
if (Number.isNaN(when)) return null;
|
|
71
|
+
return new Date(Math.max(when, fromMs)).toISOString();
|
|
72
|
+
}
|
|
73
|
+
if (type === 'cron') {
|
|
74
|
+
// Minimal cron — we only support "* * * * *" patterns for v3.
|
|
75
|
+
// Compute the next minute that matches.
|
|
76
|
+
return nextCronMinute(schedule.schedule, fromMs);
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Very minimal cron: supports star, integers, and slash-N. Returns next match. */
|
|
82
|
+
function nextCronMinute(expr, fromMs) {
|
|
83
|
+
if (typeof expr !== 'string') return null;
|
|
84
|
+
const parts = expr.trim().split(/\s+/);
|
|
85
|
+
if (parts.length !== 5) return null;
|
|
86
|
+
const [m, h, dom, mon, dow] = parts;
|
|
87
|
+
// For v3 we only support minute precision and very simple patterns.
|
|
88
|
+
// Iterate minute-by-minute up to 7 days to find the next match.
|
|
89
|
+
const start = new Date(fromMs);
|
|
90
|
+
start.setSeconds(0, 0);
|
|
91
|
+
const end = start.getTime() + 7 * 24 * 60 * 60 * 1000;
|
|
92
|
+
for (let t = start.getTime(); t <= end; t += 60 * 1000) {
|
|
93
|
+
const d = new Date(t);
|
|
94
|
+
if (!matchField(m, d.getMinutes())) continue;
|
|
95
|
+
if (!matchField(h, d.getHours())) continue;
|
|
96
|
+
if (!matchField(dom, d.getDate())) continue;
|
|
97
|
+
if (!matchField(mon, d.getMonth() + 1)) continue;
|
|
98
|
+
if (!matchField(dow, d.getDay())) continue;
|
|
99
|
+
return new Date(t).toISOString();
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function matchField(field, value) {
|
|
105
|
+
if (field === '*') return true;
|
|
106
|
+
if (field.startsWith('*/')) {
|
|
107
|
+
const n = parseInt(field.slice(2), 10);
|
|
108
|
+
return n > 0 && value % n === 0;
|
|
109
|
+
}
|
|
110
|
+
if (field.includes(',')) {
|
|
111
|
+
return field.split(',').some((p) => matchField(p.trim(), value));
|
|
112
|
+
}
|
|
113
|
+
if (field.includes('-')) {
|
|
114
|
+
const [a, b] = field.split('-').map((p) => parseInt(p, 10));
|
|
115
|
+
return value >= a && value <= b;
|
|
116
|
+
}
|
|
117
|
+
return parseInt(field, 10) === value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function loadSchedules(projectId) {
|
|
121
|
+
const dir = projectsStore.ensureProjectDir(projectId);
|
|
122
|
+
const file = join(dir, 'schedules.json');
|
|
123
|
+
const data = safeReadJSON(file, null);
|
|
124
|
+
if (!data || !Array.isArray(data.schedules)) {
|
|
125
|
+
return { version: 1, schedules: [] };
|
|
126
|
+
}
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function saveSchedules(projectId, data) {
|
|
131
|
+
const dir = projectsStore.ensureProjectDir(projectId);
|
|
132
|
+
writeFileSync(join(dir, 'schedules.json'), JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const schedulesStore = {
|
|
136
|
+
list(projectId) {
|
|
137
|
+
return loadSchedules(projectId).schedules;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
get(projectId, id) {
|
|
141
|
+
return this.list(projectId).find((s) => s.id === id) || null;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
add(projectId, input) {
|
|
145
|
+
if (!input || typeof input !== 'object') {
|
|
146
|
+
throw new Error('schedule payload required');
|
|
147
|
+
}
|
|
148
|
+
if (!input.name) throw new Error('name is required');
|
|
149
|
+
if (!['interval', 'cron', 'once'].includes(input.type)) {
|
|
150
|
+
throw new Error('type must be interval | cron | once');
|
|
151
|
+
}
|
|
152
|
+
if (!input.schedule) throw new Error('schedule is required');
|
|
153
|
+
if (!input.action || typeof input.action !== 'object') {
|
|
154
|
+
throw new Error('action is required');
|
|
155
|
+
}
|
|
156
|
+
const data = loadSchedules(projectId);
|
|
157
|
+
const now = new Date().toISOString();
|
|
158
|
+
const sched = {
|
|
159
|
+
id: genId(),
|
|
160
|
+
name: input.name,
|
|
161
|
+
type: input.type,
|
|
162
|
+
schedule: input.schedule,
|
|
163
|
+
action: input.action,
|
|
164
|
+
enabled: input.enabled !== false,
|
|
165
|
+
createdAt: now,
|
|
166
|
+
updatedAt: now,
|
|
167
|
+
lastRun: null,
|
|
168
|
+
lastResult: null,
|
|
169
|
+
nextRun: computeNextRun({ type: input.type, schedule: input.schedule }),
|
|
170
|
+
history: [],
|
|
171
|
+
};
|
|
172
|
+
data.schedules.push(sched);
|
|
173
|
+
saveSchedules(projectId, data);
|
|
174
|
+
return sched;
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
update(projectId, id, patch) {
|
|
178
|
+
const data = loadSchedules(projectId);
|
|
179
|
+
const idx = data.schedules.findIndex((s) => s.id === id);
|
|
180
|
+
if (idx === -1) return null;
|
|
181
|
+
const cur = data.schedules[idx];
|
|
182
|
+
const next = {
|
|
183
|
+
...cur,
|
|
184
|
+
...patch,
|
|
185
|
+
id: cur.id,
|
|
186
|
+
updatedAt: new Date().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
if (patch.type || patch.schedule) {
|
|
189
|
+
next.nextRun = computeNextRun({ type: next.type, schedule: next.schedule });
|
|
190
|
+
}
|
|
191
|
+
data.schedules[idx] = next;
|
|
192
|
+
saveSchedules(projectId, data);
|
|
193
|
+
return next;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
remove(projectId, id) {
|
|
197
|
+
const data = loadSchedules(projectId);
|
|
198
|
+
const before = data.schedules.length;
|
|
199
|
+
data.schedules = data.schedules.filter((s) => s.id !== id);
|
|
200
|
+
if (data.schedules.length === before) return false;
|
|
201
|
+
saveSchedules(projectId, data);
|
|
202
|
+
return true;
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/** Record the result of a run. */
|
|
206
|
+
recordRun(projectId, id, { result, error }) {
|
|
207
|
+
const data = loadSchedules(projectId);
|
|
208
|
+
const sched = data.schedules.find((s) => s.id === id);
|
|
209
|
+
if (!sched) return null;
|
|
210
|
+
const now = new Date().toISOString();
|
|
211
|
+
sched.lastRun = now;
|
|
212
|
+
sched.lastResult = result;
|
|
213
|
+
if (sched.type === 'once') {
|
|
214
|
+
sched.enabled = false;
|
|
215
|
+
} else {
|
|
216
|
+
sched.nextRun = computeNextRun(sched, Date.now());
|
|
217
|
+
}
|
|
218
|
+
sched.history = sched.history || [];
|
|
219
|
+
sched.history.push({ ts: now, result, error: error || null });
|
|
220
|
+
if (sched.history.length > 50) sched.history = sched.history.slice(-50);
|
|
221
|
+
saveSchedules(projectId, data);
|
|
222
|
+
return sched;
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
/** Return all schedules for the project that are currently due. */
|
|
226
|
+
due(projectId, now = Date.now()) {
|
|
227
|
+
return this.list(projectId).filter((s) => {
|
|
228
|
+
if (!s.enabled) return false;
|
|
229
|
+
if (!s.nextRun) return false;
|
|
230
|
+
return new Date(s.nextRun).getTime() <= now;
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/search-store.mjs
|
|
3
|
+
*
|
|
4
|
+
* v3.0.0 — Fuzzy search across projects, tasks, plans, agents, mods,
|
|
5
|
+
* commands.
|
|
6
|
+
*
|
|
7
|
+
* Uses a simple substring / token match for v3. fuse.js is wired in for
|
|
8
|
+
* fuzzy ranking but we keep a simple default that works without deps.
|
|
9
|
+
*/
|
|
10
|
+
import { agentsStore } from './agents-store.mjs';
|
|
11
|
+
import { projectsStore } from './projects-store.mjs';
|
|
12
|
+
import { tasksStore } from './tasks-store.mjs';
|
|
13
|
+
import { modsLoader } from './mods-loader.mjs';
|
|
14
|
+
import { schedulesStore } from './schedules-store.mjs';
|
|
15
|
+
|
|
16
|
+
function scoreItem(item, fields, query) {
|
|
17
|
+
if (!query) return 0;
|
|
18
|
+
const q = query.toLowerCase();
|
|
19
|
+
let best = 0;
|
|
20
|
+
for (const f of fields) {
|
|
21
|
+
const v = item[f];
|
|
22
|
+
if (typeof v !== 'string') continue;
|
|
23
|
+
const idx = v.toLowerCase().indexOf(q);
|
|
24
|
+
if (idx === -1) continue;
|
|
25
|
+
// earlier matches score higher
|
|
26
|
+
const s = 100 - Math.min(idx, 99);
|
|
27
|
+
if (s > best) best = s;
|
|
28
|
+
}
|
|
29
|
+
return best;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findPlans(planDirs) {
|
|
33
|
+
// Quick local scan — the dashboard state already lists plans, but we
|
|
34
|
+
// re-discover from the active project's plans/ dir for a self-contained
|
|
35
|
+
// search.
|
|
36
|
+
const out = [];
|
|
37
|
+
// We don't have a project plan dir in v3 search — leave it empty.
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const searchStore = {
|
|
42
|
+
search(query, { activeProjectId, scope = 'all' } = {}) {
|
|
43
|
+
const q = (query || '').trim();
|
|
44
|
+
if (!q) return { query, results: [] };
|
|
45
|
+
const results = [];
|
|
46
|
+
|
|
47
|
+
if (scope === 'all' || scope === 'projects') {
|
|
48
|
+
const reg = projectsStore.list();
|
|
49
|
+
for (const p of reg.projects) {
|
|
50
|
+
const s = scoreItem(p, ['name', 'path', 'summary'], q);
|
|
51
|
+
if (s > 0) results.push({ type: 'project', score: s, item: p });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (scope === 'all' || scope === 'agents') {
|
|
56
|
+
for (const a of agentsStore.list()) {
|
|
57
|
+
const s = scoreItem(a, ['name', 'description', 'model', 'mode'], q);
|
|
58
|
+
if (s > 0) results.push({ type: 'agent', score: s, item: a });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (scope === 'all' || scope === 'tasks') {
|
|
63
|
+
const tasks = activeProjectId
|
|
64
|
+
? tasksStore.loadTasks(activeProjectId)
|
|
65
|
+
: [];
|
|
66
|
+
for (const t of tasks) {
|
|
67
|
+
const tags = (t.tags || []).join(' ');
|
|
68
|
+
const s = scoreItem(
|
|
69
|
+
{ title: t.title || '', description: t.description || '', tags },
|
|
70
|
+
['title', 'description', 'tags'],
|
|
71
|
+
q,
|
|
72
|
+
);
|
|
73
|
+
if (s > 0) results.push({ type: 'task', score: s, item: t });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (scope === 'all' || scope === 'mods') {
|
|
78
|
+
for (const m of modsLoader.list()) {
|
|
79
|
+
const s = scoreItem(
|
|
80
|
+
{ name: m.name || '', description: m.description || '', author: m.author || '' },
|
|
81
|
+
['name', 'description', 'author'],
|
|
82
|
+
q,
|
|
83
|
+
);
|
|
84
|
+
if (s > 0) results.push({ type: 'mod', score: s, item: m });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (scope === 'all' || scope === 'schedules') {
|
|
89
|
+
const list = activeProjectId
|
|
90
|
+
? schedulesStore.list(activeProjectId)
|
|
91
|
+
: [];
|
|
92
|
+
for (const sc of list) {
|
|
93
|
+
const s = scoreItem(sc, ['name', 'schedule'], q);
|
|
94
|
+
if (s > 0) results.push({ type: 'schedule', score: s, item: sc });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (scope === 'all' || scope === 'commands') {
|
|
99
|
+
// Built-in slash commands + a placeholder for the user's
|
|
100
|
+
// commands-bizar folder.
|
|
101
|
+
const builtin = [
|
|
102
|
+
{ name: 'plan', description: 'Manage visual plans' },
|
|
103
|
+
{ name: 'audit', description: 'Run security audit' },
|
|
104
|
+
{ name: 'init', description: 'Initialize .bizar/ in this project' },
|
|
105
|
+
{ name: 'update', description: 'Update bizar + plugin' },
|
|
106
|
+
{ name: 'export', description: 'Export to another harness' },
|
|
107
|
+
{ name: 'service', description: 'Manage the service daemon' },
|
|
108
|
+
{ name: 'explain', description: 'Read-only code Q&A' },
|
|
109
|
+
{ name: 'pr-review', description: 'PR review with @mimir + @forseti' },
|
|
110
|
+
];
|
|
111
|
+
for (const c of builtin) {
|
|
112
|
+
const s = scoreItem(c, ['name', 'description'], q);
|
|
113
|
+
if (s > 0) results.push({ type: 'command', score: s, item: c });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
results.sort((a, b) => b.score - a.score);
|
|
118
|
+
return { query, results: results.slice(0, 50) };
|
|
119
|
+
},
|
|
120
|
+
};
|