@way_marks/server 0.6.0 → 0.8.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/api/server.js +138 -0
- package/dist/db/database.js +223 -2
- package/dist/mcp/server.js +24 -0
- package/package.json +1 -1
- package/src/ui/index.html +168 -7
- package/src/ui/index.html.bak +429 -0
package/dist/api/server.js
CHANGED
|
@@ -43,6 +43,52 @@ const path = __importStar(require("path"));
|
|
|
43
43
|
const database_1 = require("../db/database");
|
|
44
44
|
const engine_1 = require("../policies/engine");
|
|
45
45
|
const handler_1 = require("../approvals/handler");
|
|
46
|
+
// Import registry for Phase 2 hub navigation
|
|
47
|
+
const registryPath = path.join(process.env.HOME || process.env.USERPROFILE || '', '.waymark', 'registry.json');
|
|
48
|
+
function getRegistryProjects() {
|
|
49
|
+
try {
|
|
50
|
+
if (!fs.existsSync(registryPath))
|
|
51
|
+
return [];
|
|
52
|
+
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
53
|
+
return registry.projects || [];
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Phase 4: Garbage collection for registry
|
|
60
|
+
function garbageCollectRegistryFile() {
|
|
61
|
+
try {
|
|
62
|
+
if (!fs.existsSync(registryPath))
|
|
63
|
+
return 0;
|
|
64
|
+
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
65
|
+
const cutoffTime = new Date();
|
|
66
|
+
cutoffTime.setDate(cutoffTime.getDate() - 7);
|
|
67
|
+
let removed = 0;
|
|
68
|
+
const projects = registry.projects || {};
|
|
69
|
+
const idsToRemove = [];
|
|
70
|
+
for (const [id, entry] of Object.entries(projects)) {
|
|
71
|
+
if (entry.status === 'stopped' && entry.stoppedAt) {
|
|
72
|
+
const stoppedTime = new Date(entry.stoppedAt);
|
|
73
|
+
if (stoppedTime < cutoffTime) {
|
|
74
|
+
idsToRemove.push(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const id of idsToRemove) {
|
|
79
|
+
delete registry.projects[id];
|
|
80
|
+
removed++;
|
|
81
|
+
}
|
|
82
|
+
if (removed > 0) {
|
|
83
|
+
registry.lastUpdated = new Date().toISOString();
|
|
84
|
+
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
|
|
85
|
+
}
|
|
86
|
+
return removed;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
46
92
|
const app = (0, express_1.default)();
|
|
47
93
|
const PORT = parseInt(process.env.WAYMARK_PORT || '3001', 10);
|
|
48
94
|
app.use(express_1.default.json());
|
|
@@ -63,6 +109,78 @@ app.get('/api/actions', (req, res) => {
|
|
|
63
109
|
res.status(500).json({ error: err.message });
|
|
64
110
|
}
|
|
65
111
|
});
|
|
112
|
+
// Phase 3: GET /api/actions/paginated — paginated actions with filtering
|
|
113
|
+
app.get('/api/actions/paginated', (req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
const filter = {
|
|
116
|
+
status: req.query.status,
|
|
117
|
+
tool_name: req.query.tool_name,
|
|
118
|
+
search: req.query.search,
|
|
119
|
+
page: parseInt(req.query.page) || 1,
|
|
120
|
+
limit: parseInt(req.query.limit) || 50,
|
|
121
|
+
};
|
|
122
|
+
const result = (0, database_1.getActionsWithFiltering)(filter);
|
|
123
|
+
res.json(result);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
res.status(500).json({ error: err.message });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
// Phase 3: GET /api/stats — summary statistics
|
|
130
|
+
app.get('/api/stats', (req, res) => {
|
|
131
|
+
try {
|
|
132
|
+
const stats = (0, database_1.getSummaryStats)();
|
|
133
|
+
res.json(stats);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
res.status(500).json({ error: err.message });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
// Phase 5B: POST /api/cli-action — log GitHub Copilot CLI command
|
|
140
|
+
// Called by copilot-cli-wrapper.sh when user runs: copilot [command]
|
|
141
|
+
app.post('/api/cli-action', (req, res) => {
|
|
142
|
+
try {
|
|
143
|
+
const { command, args, cwd, timestamp, shell, user } = req.body;
|
|
144
|
+
if (!command) {
|
|
145
|
+
return res.status(400).json({ error: 'Missing command field' });
|
|
146
|
+
}
|
|
147
|
+
// Generate action ID and session ID
|
|
148
|
+
const action_id = `cli-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
149
|
+
const session_id = 'cli-session'; // All CLI actions share same session
|
|
150
|
+
// Log CLI action using insertAction function
|
|
151
|
+
(0, database_1.insertAction)({
|
|
152
|
+
action_id,
|
|
153
|
+
session_id,
|
|
154
|
+
tool_name: 'copilot',
|
|
155
|
+
input_payload: JSON.stringify({ command, args, cwd, shell, user }),
|
|
156
|
+
status: 'executed', // CLI always executes (no approval flow)
|
|
157
|
+
event_type: 'execution',
|
|
158
|
+
observation_context: `CLI: ${command} ${args}`,
|
|
159
|
+
request_source: 'cli',
|
|
160
|
+
source: 'cli', // Distinguish from MCP
|
|
161
|
+
});
|
|
162
|
+
res.json({
|
|
163
|
+
success: true,
|
|
164
|
+
action_id,
|
|
165
|
+
message: `Logged Copilot CLI: ${command} ${args}`
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
console.error('Error logging CLI action:', err);
|
|
170
|
+
res.status(500).json({ error: err.message });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
// Phase 3: POST /api/maintenance/archive — archive old actions
|
|
174
|
+
app.post('/api/maintenance/archive', (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
const daysOld = parseInt(req.body?.daysOld) || 30;
|
|
177
|
+
const archived = (0, database_1.archiveOldActions)(daysOld);
|
|
178
|
+
res.json({ success: true, archived, message: `Archived ${archived} actions older than ${daysOld} days` });
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
res.status(500).json({ error: err.message });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
66
184
|
// POST /api/actions/:action_id/approve
|
|
67
185
|
app.post('/api/actions/:action_id/approve', async (req, res) => {
|
|
68
186
|
try {
|
|
@@ -222,6 +340,26 @@ app.get('/api/project', (req, res) => {
|
|
|
222
340
|
res.status(500).json({ error: err.message });
|
|
223
341
|
}
|
|
224
342
|
});
|
|
343
|
+
// GET /api/hub/projects — Phase 2: returns all registered projects (optional hub feature)
|
|
344
|
+
app.get('/api/hub/projects', (req, res) => {
|
|
345
|
+
try {
|
|
346
|
+
const projects = getRegistryProjects();
|
|
347
|
+
res.json(projects);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
res.status(500).json({ error: err.message });
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// Phase 4: POST /api/registry/cleanup — garbage collect stale entries
|
|
354
|
+
app.post('/api/registry/cleanup', (req, res) => {
|
|
355
|
+
try {
|
|
356
|
+
const removed = garbageCollectRegistryFile();
|
|
357
|
+
res.json({ success: true, removed, message: `Garbage collected ${removed} stale entries` });
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
res.status(500).json({ error: err.message });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
225
363
|
// Fallback: serve UI for any unmatched route
|
|
226
364
|
app.get('*', (req, res) => {
|
|
227
365
|
res.sendFile(path.join(UI_DIR, 'index.html'));
|
package/dist/db/database.js
CHANGED
|
@@ -45,6 +45,10 @@ exports.getSessions = getSessions;
|
|
|
45
45
|
exports.approveAction = approveAction;
|
|
46
46
|
exports.rejectAction = rejectAction;
|
|
47
47
|
exports.getPendingCount = getPendingCount;
|
|
48
|
+
exports.getActionsWithFiltering = getActionsWithFiltering;
|
|
49
|
+
exports.archiveOldActions = archiveOldActions;
|
|
50
|
+
exports.getArchivedActionsWithFiltering = getArchivedActionsWithFiltering;
|
|
51
|
+
exports.getSummaryStats = getSummaryStats;
|
|
48
52
|
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
49
53
|
const fs = __importStar(require("fs"));
|
|
50
54
|
const path = __importStar(require("path"));
|
|
@@ -116,6 +120,24 @@ try {
|
|
|
116
120
|
db.exec('ALTER TABLE action_log ADD COLUMN rejected_reason TEXT');
|
|
117
121
|
}
|
|
118
122
|
catch { }
|
|
123
|
+
// Migrate v4: Phase 1 — plan mode logging visibility
|
|
124
|
+
try {
|
|
125
|
+
db.exec("ALTER TABLE action_log ADD COLUMN event_type TEXT NOT NULL DEFAULT 'execution'");
|
|
126
|
+
}
|
|
127
|
+
catch { }
|
|
128
|
+
try {
|
|
129
|
+
db.exec('ALTER TABLE action_log ADD COLUMN observation_context TEXT');
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
try {
|
|
133
|
+
db.exec('ALTER TABLE action_log ADD COLUMN request_source TEXT DEFAULT \'direct\'');
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
// Migrate v5: Phase 5B — CLI action logging (GitHub Copilot CLI wrapper)
|
|
137
|
+
try {
|
|
138
|
+
db.exec("ALTER TABLE action_log ADD COLUMN source TEXT DEFAULT 'mcp'");
|
|
139
|
+
}
|
|
140
|
+
catch { }
|
|
119
141
|
// Indexes for query performance
|
|
120
142
|
try {
|
|
121
143
|
db.exec('CREATE INDEX IF NOT EXISTS idx_action_id ON action_log(action_id)');
|
|
@@ -129,9 +151,62 @@ try {
|
|
|
129
151
|
db.exec('CREATE INDEX IF NOT EXISTS idx_session_id ON action_log(session_id)');
|
|
130
152
|
}
|
|
131
153
|
catch { }
|
|
154
|
+
// Phase 3: Add indexes for pagination and filtering
|
|
155
|
+
try {
|
|
156
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_tool_name ON action_log(tool_name)');
|
|
157
|
+
}
|
|
158
|
+
catch { }
|
|
159
|
+
try {
|
|
160
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_created_at ON action_log(created_at DESC)');
|
|
161
|
+
}
|
|
162
|
+
catch { }
|
|
163
|
+
try {
|
|
164
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_status_created ON action_log(status, created_at DESC)');
|
|
165
|
+
}
|
|
166
|
+
catch { }
|
|
167
|
+
// Phase 3: Create archive table (same schema as action_log)
|
|
168
|
+
db.exec(`
|
|
169
|
+
CREATE TABLE IF NOT EXISTS action_archive (
|
|
170
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
171
|
+
action_id TEXT UNIQUE NOT NULL,
|
|
172
|
+
session_id TEXT NOT NULL,
|
|
173
|
+
tool_name TEXT NOT NULL,
|
|
174
|
+
target_path TEXT,
|
|
175
|
+
input_payload TEXT NOT NULL,
|
|
176
|
+
before_snapshot TEXT,
|
|
177
|
+
after_snapshot TEXT,
|
|
178
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
179
|
+
error_message TEXT,
|
|
180
|
+
stdout TEXT,
|
|
181
|
+
stderr TEXT,
|
|
182
|
+
rolled_back INTEGER NOT NULL DEFAULT 0,
|
|
183
|
+
rolled_back_at TEXT,
|
|
184
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
185
|
+
decision TEXT NOT NULL DEFAULT 'allow',
|
|
186
|
+
policy_reason TEXT,
|
|
187
|
+
matched_rule TEXT,
|
|
188
|
+
approved_at TEXT,
|
|
189
|
+
approved_by TEXT,
|
|
190
|
+
rejected_at TEXT,
|
|
191
|
+
rejected_reason TEXT,
|
|
192
|
+
event_type TEXT NOT NULL DEFAULT 'execution',
|
|
193
|
+
observation_context TEXT,
|
|
194
|
+
request_source TEXT DEFAULT 'direct',
|
|
195
|
+
source TEXT DEFAULT 'mcp'
|
|
196
|
+
)
|
|
197
|
+
`);
|
|
198
|
+
// Phase 3: Add indexes on archive table
|
|
199
|
+
try {
|
|
200
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_archive_action_id ON action_archive(action_id)');
|
|
201
|
+
}
|
|
202
|
+
catch { }
|
|
203
|
+
try {
|
|
204
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_archive_created_at ON action_archive(created_at DESC)');
|
|
205
|
+
}
|
|
206
|
+
catch { }
|
|
132
207
|
const insertStmt = db.prepare(`
|
|
133
|
-
INSERT INTO action_log (action_id, session_id, tool_name, target_path, input_payload, before_snapshot, status, decision, policy_reason, matched_rule)
|
|
134
|
-
VALUES (@action_id, @session_id, @tool_name, @target_path, @input_payload, @before_snapshot, @status, @decision, @policy_reason, @matched_rule)
|
|
208
|
+
INSERT INTO action_log (action_id, session_id, tool_name, target_path, input_payload, before_snapshot, status, decision, policy_reason, matched_rule, event_type, observation_context, request_source, source)
|
|
209
|
+
VALUES (@action_id, @session_id, @tool_name, @target_path, @input_payload, @before_snapshot, @status, @decision, @policy_reason, @matched_rule, @event_type, @observation_context, @request_source, @source)
|
|
135
210
|
`);
|
|
136
211
|
const updateStmt = db.prepare(`
|
|
137
212
|
UPDATE action_log
|
|
@@ -154,6 +229,10 @@ function insertAction(params) {
|
|
|
154
229
|
decision: params.decision ?? 'pending',
|
|
155
230
|
policy_reason: params.policy_reason ?? null,
|
|
156
231
|
matched_rule: params.matched_rule ?? null,
|
|
232
|
+
event_type: params.event_type ?? 'execution',
|
|
233
|
+
observation_context: params.observation_context ?? null,
|
|
234
|
+
request_source: params.request_source ?? 'direct',
|
|
235
|
+
source: params.source ?? 'mcp',
|
|
157
236
|
});
|
|
158
237
|
}
|
|
159
238
|
function updateAction(action_id, params) {
|
|
@@ -220,4 +299,146 @@ function getPendingCount() {
|
|
|
220
299
|
`).get();
|
|
221
300
|
return row.count;
|
|
222
301
|
}
|
|
302
|
+
function getActionsWithFiltering(filter = {}) {
|
|
303
|
+
const page = Math.max(1, filter.page ?? 1);
|
|
304
|
+
const limit = Math.min(filter.limit ?? 50, 200); // max 200 per page
|
|
305
|
+
const offset = (page - 1) * limit;
|
|
306
|
+
let where = '1=1';
|
|
307
|
+
const params = {};
|
|
308
|
+
if (filter.status) {
|
|
309
|
+
where += ' AND status = @status';
|
|
310
|
+
params.status = filter.status;
|
|
311
|
+
}
|
|
312
|
+
if (filter.tool_name) {
|
|
313
|
+
where += ' AND tool_name = @tool_name';
|
|
314
|
+
params.tool_name = filter.tool_name;
|
|
315
|
+
}
|
|
316
|
+
if (filter.search) {
|
|
317
|
+
where += ' AND (action_id LIKE @search OR target_path LIKE @search)';
|
|
318
|
+
params.search = `%${filter.search}%`;
|
|
319
|
+
}
|
|
320
|
+
// Get total count
|
|
321
|
+
const countRow = db.prepare(`
|
|
322
|
+
SELECT COUNT(*) as count FROM action_log WHERE ${where}
|
|
323
|
+
`).get(params);
|
|
324
|
+
const totalCount = countRow.count;
|
|
325
|
+
// Get paginated results
|
|
326
|
+
const actions = db.prepare(`
|
|
327
|
+
SELECT * FROM action_log WHERE ${where}
|
|
328
|
+
ORDER BY created_at DESC
|
|
329
|
+
LIMIT @limit OFFSET @offset
|
|
330
|
+
`).all({ ...params, limit, offset });
|
|
331
|
+
return {
|
|
332
|
+
actions,
|
|
333
|
+
totalCount,
|
|
334
|
+
page,
|
|
335
|
+
limit,
|
|
336
|
+
hasMore: offset + actions.length < totalCount,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function archiveOldActions(daysOld = 30) {
|
|
340
|
+
const cutoffDate = new Date();
|
|
341
|
+
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
|
342
|
+
const cutoffStr = cutoffDate.toISOString();
|
|
343
|
+
// Copy old actions to archive
|
|
344
|
+
const result = db.prepare(`
|
|
345
|
+
INSERT OR IGNORE INTO action_archive
|
|
346
|
+
SELECT * FROM action_log WHERE created_at < @cutoff
|
|
347
|
+
`).run({ cutoff: cutoffStr });
|
|
348
|
+
// Delete from main table (but keep recent entries)
|
|
349
|
+
const deleteStmt = db.prepare(`
|
|
350
|
+
DELETE FROM action_log WHERE created_at < @cutoff
|
|
351
|
+
AND id NOT IN (
|
|
352
|
+
SELECT id FROM action_log ORDER BY created_at DESC LIMIT 1000
|
|
353
|
+
)
|
|
354
|
+
`);
|
|
355
|
+
deleteStmt.run({ cutoff: cutoffStr });
|
|
356
|
+
return result.changes;
|
|
357
|
+
}
|
|
358
|
+
function getArchivedActionsWithFiltering(filter = {}) {
|
|
359
|
+
const page = Math.max(1, filter.page ?? 1);
|
|
360
|
+
const limit = Math.min(filter.limit ?? 50, 200);
|
|
361
|
+
const offset = (page - 1) * limit;
|
|
362
|
+
let where = '1=1';
|
|
363
|
+
const params = {};
|
|
364
|
+
if (filter.status) {
|
|
365
|
+
where += ' AND status = @status';
|
|
366
|
+
params.status = filter.status;
|
|
367
|
+
}
|
|
368
|
+
if (filter.tool_name) {
|
|
369
|
+
where += ' AND tool_name = @tool_name';
|
|
370
|
+
params.tool_name = filter.tool_name;
|
|
371
|
+
}
|
|
372
|
+
if (filter.search) {
|
|
373
|
+
where += ' AND (action_id LIKE @search OR target_path LIKE @search)';
|
|
374
|
+
params.search = `%${filter.search}%`;
|
|
375
|
+
}
|
|
376
|
+
const countRow = db.prepare(`
|
|
377
|
+
SELECT COUNT(*) as count FROM action_archive WHERE ${where}
|
|
378
|
+
`).get(params);
|
|
379
|
+
const totalCount = countRow.count;
|
|
380
|
+
const actions = db.prepare(`
|
|
381
|
+
SELECT * FROM action_archive WHERE ${where}
|
|
382
|
+
ORDER BY created_at DESC
|
|
383
|
+
LIMIT @limit OFFSET @offset
|
|
384
|
+
`).all({ ...params, limit, offset });
|
|
385
|
+
return {
|
|
386
|
+
actions,
|
|
387
|
+
totalCount,
|
|
388
|
+
page,
|
|
389
|
+
limit,
|
|
390
|
+
hasMore: offset + actions.length < totalCount,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function getSummaryStats() {
|
|
394
|
+
const now = new Date();
|
|
395
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
|
|
396
|
+
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
397
|
+
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
398
|
+
const total = db.prepare(`
|
|
399
|
+
SELECT COUNT(*) as count FROM action_log
|
|
400
|
+
`).get();
|
|
401
|
+
const pending = db.prepare(`
|
|
402
|
+
SELECT COUNT(*) as count FROM action_log WHERE status = 'pending'
|
|
403
|
+
`).get();
|
|
404
|
+
const approved = db.prepare(`
|
|
405
|
+
SELECT COUNT(*) as count FROM action_log WHERE status = 'success' OR decision = 'allow'
|
|
406
|
+
`).get();
|
|
407
|
+
const rejected = db.prepare(`
|
|
408
|
+
SELECT COUNT(*) as count FROM action_log WHERE status = 'rejected' OR decision = 'rejected'
|
|
409
|
+
`).get();
|
|
410
|
+
const todayCount = db.prepare(`
|
|
411
|
+
SELECT COUNT(*) as count FROM action_log WHERE created_at >= @today
|
|
412
|
+
`).get({ today });
|
|
413
|
+
const weekCount = db.prepare(`
|
|
414
|
+
SELECT COUNT(*) as count FROM action_log WHERE created_at >= @weekAgo
|
|
415
|
+
`).get({ weekAgo });
|
|
416
|
+
const monthCount = db.prepare(`
|
|
417
|
+
SELECT COUNT(*) as count FROM action_log WHERE created_at >= @monthAgo
|
|
418
|
+
`).get({ monthAgo });
|
|
419
|
+
const topTools = db.prepare(`
|
|
420
|
+
SELECT tool_name as tool, COUNT(*) as count FROM action_log
|
|
421
|
+
GROUP BY tool_name
|
|
422
|
+
ORDER BY count DESC
|
|
423
|
+
LIMIT 5
|
|
424
|
+
`).all();
|
|
425
|
+
const topPaths = db.prepare(`
|
|
426
|
+
SELECT target_path as path, COUNT(*) as count FROM action_log
|
|
427
|
+
WHERE target_path IS NOT NULL
|
|
428
|
+
GROUP BY target_path
|
|
429
|
+
ORDER BY count DESC
|
|
430
|
+
LIMIT 5
|
|
431
|
+
`).all();
|
|
432
|
+
return {
|
|
433
|
+
totalActions: total.count,
|
|
434
|
+
pendingCount: pending.count,
|
|
435
|
+
approvedCount: approved.count,
|
|
436
|
+
rejectedCount: rejected.count,
|
|
437
|
+
todayCount: todayCount.count,
|
|
438
|
+
thisWeekCount: weekCount.count,
|
|
439
|
+
thisMonthCount: monthCount.count,
|
|
440
|
+
topTools,
|
|
441
|
+
topPaths,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
223
444
|
exports.default = db;
|
package/dist/mcp/server.js
CHANGED
|
@@ -125,6 +125,8 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
125
125
|
if (name === 'write_file') {
|
|
126
126
|
const { path: filePath, content } = args;
|
|
127
127
|
const resolvedPath = path.resolve(filePath);
|
|
128
|
+
const requestSource = 'direct';
|
|
129
|
+
const observationContext = null;
|
|
128
130
|
// Policy check before execution
|
|
129
131
|
const config = (0, engine_1.loadConfig)();
|
|
130
132
|
const policyResult = (0, engine_1.checkFileAction)(resolvedPath, 'write', config);
|
|
@@ -133,6 +135,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
133
135
|
action_id, session_id: SESSION_ID, tool_name: 'write_file',
|
|
134
136
|
target_path: resolvedPath, input_payload, status: 'blocked',
|
|
135
137
|
decision: 'block', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
|
|
138
|
+
event_type: 'execution', request_source: requestSource, observation_context: observationContext,
|
|
136
139
|
});
|
|
137
140
|
throw new Error(`Waymark blocked: ${policyResult.reason} [rule: ${policyResult.matchedRule}]`);
|
|
138
141
|
}
|
|
@@ -141,6 +144,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
141
144
|
action_id, session_id: SESSION_ID, tool_name: 'write_file',
|
|
142
145
|
target_path: resolvedPath, input_payload, status: 'pending',
|
|
143
146
|
decision: 'pending', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
|
|
147
|
+
event_type: 'execution', request_source: requestSource, observation_context: observationContext,
|
|
144
148
|
});
|
|
145
149
|
// Fire-and-forget Slack notification (no console.log — MCP uses stdio)
|
|
146
150
|
(0, slack_1.notifyPendingAction)({
|
|
@@ -152,6 +156,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
152
156
|
created_at: new Date().toISOString(),
|
|
153
157
|
decision: 'pending', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
|
|
154
158
|
approved_at: null, approved_by: null, rejected_at: null, rejected_reason: null,
|
|
159
|
+
event_type: 'execution', observation_context: null, request_source: 'direct',
|
|
155
160
|
id: 0,
|
|
156
161
|
}).catch(() => { });
|
|
157
162
|
return {
|
|
@@ -180,6 +185,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
180
185
|
decision: policyResult.decision,
|
|
181
186
|
policy_reason: policyResult.reason,
|
|
182
187
|
matched_rule: policyResult.matchedRule ?? null,
|
|
188
|
+
event_type: 'execution',
|
|
189
|
+
request_source: requestSource,
|
|
190
|
+
observation_context: observationContext,
|
|
183
191
|
});
|
|
184
192
|
try {
|
|
185
193
|
// Ensure directory exists
|
|
@@ -204,6 +212,10 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
204
212
|
else if (name === 'read_file') {
|
|
205
213
|
const { path: filePath } = args;
|
|
206
214
|
const resolvedPath = path.resolve(filePath);
|
|
215
|
+
// Determine request source for observability (plan mode vs direct execution)
|
|
216
|
+
// We track "observation" for planning-phase reads if patterns suggest it
|
|
217
|
+
const requestSource = 'direct';
|
|
218
|
+
const observationContext = null;
|
|
207
219
|
// Policy check before execution
|
|
208
220
|
const config = (0, engine_1.loadConfig)();
|
|
209
221
|
const policyResult = (0, engine_1.checkFileAction)(resolvedPath, 'read', config);
|
|
@@ -212,6 +224,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
212
224
|
action_id, session_id: SESSION_ID, tool_name: 'read_file',
|
|
213
225
|
target_path: resolvedPath, input_payload, status: 'blocked',
|
|
214
226
|
decision: 'block', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
|
|
227
|
+
event_type: 'execution', request_source: requestSource, observation_context: observationContext,
|
|
215
228
|
});
|
|
216
229
|
throw new Error(`Waymark blocked: ${policyResult.reason} [rule: ${policyResult.matchedRule}]`);
|
|
217
230
|
}
|
|
@@ -220,6 +233,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
220
233
|
action_id, session_id: SESSION_ID, tool_name: 'read_file',
|
|
221
234
|
target_path: resolvedPath, input_payload, status: 'pending',
|
|
222
235
|
decision: 'pending', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
|
|
236
|
+
event_type: 'execution', request_source: requestSource, observation_context: observationContext,
|
|
223
237
|
});
|
|
224
238
|
(0, slack_1.notifyPendingAction)({
|
|
225
239
|
action_id, session_id: SESSION_ID, tool_name: 'read_file',
|
|
@@ -230,6 +244,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
230
244
|
created_at: new Date().toISOString(),
|
|
231
245
|
decision: 'pending', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
|
|
232
246
|
approved_at: null, approved_by: null, rejected_at: null, rejected_reason: null,
|
|
247
|
+
event_type: 'execution', observation_context: null, request_source: 'direct',
|
|
233
248
|
id: 0,
|
|
234
249
|
}).catch(() => { });
|
|
235
250
|
return {
|
|
@@ -249,6 +264,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
249
264
|
decision: policyResult.decision,
|
|
250
265
|
policy_reason: policyResult.reason,
|
|
251
266
|
matched_rule: policyResult.matchedRule ?? null,
|
|
267
|
+
event_type: 'execution',
|
|
268
|
+
request_source: requestSource,
|
|
269
|
+
observation_context: observationContext,
|
|
252
270
|
});
|
|
253
271
|
try {
|
|
254
272
|
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
@@ -270,6 +288,8 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
270
288
|
}
|
|
271
289
|
else if (name === 'bash') {
|
|
272
290
|
const { command } = args;
|
|
291
|
+
const requestSource = 'direct';
|
|
292
|
+
const observationContext = null;
|
|
273
293
|
// Policy check before execution
|
|
274
294
|
const config = (0, engine_1.loadConfig)();
|
|
275
295
|
const policyResult = (0, engine_1.checkBashAction)(command, config);
|
|
@@ -278,6 +298,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
278
298
|
action_id, session_id: SESSION_ID, tool_name: 'bash',
|
|
279
299
|
target_path: null, input_payload, status: 'blocked',
|
|
280
300
|
decision: 'block', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
|
|
301
|
+
event_type: 'execution', request_source: requestSource, observation_context: observationContext,
|
|
281
302
|
});
|
|
282
303
|
throw new Error(`Waymark blocked command: ${policyResult.reason} [rule: ${policyResult.matchedRule}]`);
|
|
283
304
|
}
|
|
@@ -291,6 +312,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
291
312
|
decision: policyResult.decision,
|
|
292
313
|
policy_reason: policyResult.reason,
|
|
293
314
|
matched_rule: policyResult.matchedRule ?? null,
|
|
315
|
+
event_type: 'execution',
|
|
316
|
+
request_source: requestSource,
|
|
317
|
+
observation_context: observationContext,
|
|
294
318
|
});
|
|
295
319
|
// Bug 1 + Bug 2: use spawnSync for clean stdout/stderr separation and USER_PATH
|
|
296
320
|
const result = (0, child_process_1.spawnSync)('sh', ['-c', command], {
|
package/package.json
CHANGED
package/src/ui/index.html
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
.badge-write_file { background: #7c3a00; color: #ffb347; }
|
|
18
18
|
.badge-read_file { background: #002f5c; color: #5ab4ff; }
|
|
19
19
|
.badge-bash { background: #222; color: #aaa; border: 1px solid #444; }
|
|
20
|
+
.event-execution { opacity: 1; }
|
|
21
|
+
.event-observation { opacity: 0.6; background: #0a0a0a; }
|
|
22
|
+
.observation-label { display: inline-block; padding: 1px 4px; background: #333; color: #999; font-size: 10px; border-radius: 2px; margin-left: 4px; }
|
|
20
23
|
.decision-block { background: #5c0000; color: #ff6b6b; }
|
|
21
24
|
.decision-pending { background: #4a3800; color: #ffd166; }
|
|
22
25
|
.decision-rejected { background: #3a0000; color: #ff9999; }
|
|
@@ -81,6 +84,25 @@
|
|
|
81
84
|
background: #7f0000; color: #ef9a9a; border-radius: 3px;
|
|
82
85
|
padding: 1px 6px; font-size: 13px; margin-left: 8px;
|
|
83
86
|
}
|
|
87
|
+
|
|
88
|
+
/* Phase 2: Hub navigation sidebar */
|
|
89
|
+
#hub-nav { display: none; position: fixed; left: 0; top: 0; width: 200px; height: 100vh; background: #0a0a0a; border-right: 1px solid #222; padding: 12px; overflow-y: auto; }
|
|
90
|
+
#hub-nav.open { display: block; }
|
|
91
|
+
#main-content { margin-left: 0; }
|
|
92
|
+
#main-content.with-nav { margin-left: 200px; }
|
|
93
|
+
#hub-toggle { position: fixed; left: 8px; top: 8px; background: none; border: 1px solid #333; color: #888; padding: 4px 6px; cursor: pointer; font-size: 12px; border-radius: 3px; z-index: 1000; }
|
|
94
|
+
#hub-nav h3 { font-size: 11px; color: #666; margin: 12px 0 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
95
|
+
.project-list { list-style: none; padding: 0; }
|
|
96
|
+
.project-item { padding: 6px 4px; margin: 2px 0; border-radius: 2px; cursor: pointer; font-size: 12px; border-left: 2px solid transparent; }
|
|
97
|
+
.project-item.running { color: #81c784; border-left-color: #4caf50; }
|
|
98
|
+
.project-item.running:hover { background: #1a1a1a; }
|
|
99
|
+
.project-item.paused { color: #ffd166; border-left-color: #ffa000; }
|
|
100
|
+
.project-item.paused:hover { background: #1a1a1a; }
|
|
101
|
+
.project-item.stopped { color: #888; border-left-color: #555; }
|
|
102
|
+
.project-item.stopped:hover { background: #1a1a1a; }
|
|
103
|
+
.project-item.current { background: #1a1a1a; border-left-color: #5ab4ff; }
|
|
104
|
+
.project-port { font-size: 10px; color: #555; margin-left: 4px; }
|
|
105
|
+
|
|
84
106
|
</style>
|
|
85
107
|
</head>
|
|
86
108
|
<body>
|
|
@@ -90,6 +112,47 @@
|
|
|
90
112
|
<span id="count-display">loading...</span>
|
|
91
113
|
<span id="refresh-display"></span>
|
|
92
114
|
</div>
|
|
115
|
+
<div style="margin-bottom:16px;font-size:12px">
|
|
116
|
+
<!-- Phase 1: Event type filters -->
|
|
117
|
+
<div style="margin-bottom:12px">
|
|
118
|
+
<span style="color:#888">Event:</span>
|
|
119
|
+
<label style="margin-left:8px"><input type="checkbox" id="filter-execution" checked> Execution</label>
|
|
120
|
+
<label style="margin-left:12px"><input type="checkbox" id="filter-observation" checked> Observation</label>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<!-- Phase 3: Status and tool filters -->
|
|
124
|
+
<div style="margin-bottom:12px">
|
|
125
|
+
<span style="color:#888">Status:</span>
|
|
126
|
+
<select id="filter-status" style="margin-left:8px;padding:2px;font-family:monospace;font-size:11px;background:#1a1a1a;color:#d0d0d0;border:1px solid #333">
|
|
127
|
+
<option value="">All Statuses</option>
|
|
128
|
+
<option value="pending">Pending</option>
|
|
129
|
+
<option value="success">Success</option>
|
|
130
|
+
<option value="rejected">Rejected</option>
|
|
131
|
+
</select>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div style="margin-bottom:12px">
|
|
135
|
+
<span style="color:#888">Tool:</span>
|
|
136
|
+
<select id="filter-tool" style="margin-left:8px;padding:2px;font-family:monospace;font-size:11px;background:#1a1a1a;color:#d0d0d0;border:1px solid #333">
|
|
137
|
+
<option value="">All Tools</option>
|
|
138
|
+
<option value="write_file">write_file</option>
|
|
139
|
+
<option value="read_file">read_file</option>
|
|
140
|
+
<option value="bash">bash</option>
|
|
141
|
+
</select>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div style="margin-bottom:12px">
|
|
145
|
+
<span style="color:#888">Search:</span>
|
|
146
|
+
<input type="text" id="filter-search" placeholder="action ID or path..." style="margin-left:8px;padding:2px;font-family:monospace;font-size:11px;background:#1a1a1a;color:#d0d0d0;border:1px solid #333;width:200px">
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<button id="hub-toggle">📋</button>
|
|
151
|
+
<nav id="hub-nav">
|
|
152
|
+
<h3>Projects</h3>
|
|
153
|
+
<ul id="project-list" class="project-list"></ul>
|
|
154
|
+
</nav>
|
|
155
|
+
<div id="main-content">
|
|
93
156
|
<table>
|
|
94
157
|
<thead>
|
|
95
158
|
<tr>
|
|
@@ -117,6 +180,7 @@
|
|
|
117
180
|
|
|
118
181
|
<div id="status-bar">auto-refresh every 3s</div>
|
|
119
182
|
|
|
183
|
+
</div>
|
|
120
184
|
<script>
|
|
121
185
|
function timeAgo(dateStr) {
|
|
122
186
|
const now = new Date();
|
|
@@ -270,7 +334,17 @@
|
|
|
270
334
|
return;
|
|
271
335
|
}
|
|
272
336
|
|
|
273
|
-
|
|
337
|
+
// Get filter state
|
|
338
|
+
const showExecution = document.getElementById('filter-execution').checked;
|
|
339
|
+
const showObservation = document.getElementById('filter-observation').checked;
|
|
340
|
+
|
|
341
|
+
const filteredActions = actions.filter(row => {
|
|
342
|
+
const eventType = row.event_type || 'execution';
|
|
343
|
+
if (eventType === 'observation') return showObservation;
|
|
344
|
+
return showExecution;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const rows = filteredActions.map(row => {
|
|
274
348
|
const isWriteFile = row.tool_name === 'write_file';
|
|
275
349
|
const isNewFile = isWriteFile && !row.before_snapshot;
|
|
276
350
|
const isPending = row.decision === 'pending' && row.status === 'pending';
|
|
@@ -278,6 +352,8 @@
|
|
|
278
352
|
const isRejected = row.status === 'rejected';
|
|
279
353
|
const isBlocked = row.decision === 'block';
|
|
280
354
|
const canRollback = isWriteFile && !row.rolled_back && !isBlocked && !isPending && !isApproved && row.status === 'success' && !row.approved_by;
|
|
355
|
+
const eventType = row.event_type || 'execution';
|
|
356
|
+
const isObservation = eventType === 'observation';
|
|
281
357
|
|
|
282
358
|
let actionCell;
|
|
283
359
|
if (isPending) {
|
|
@@ -301,9 +377,12 @@
|
|
|
301
377
|
? `<span style="color:#666;font-size:11px">${row.stdout.slice(0, 100).replace(/\n/g, '↵')}</span>`
|
|
302
378
|
: `<span style="color:#333">—</span>`;
|
|
303
379
|
|
|
304
|
-
|
|
380
|
+
const rowClass = `event-${eventType}`;
|
|
381
|
+
const obsLabel = isObservation ? '<span class="observation-label">plan mode</span>' : '';
|
|
382
|
+
|
|
383
|
+
return `<tr class="${rowClass}">
|
|
305
384
|
<td style="white-space:nowrap;color:#555">${timeAgo(row.created_at)}</td>
|
|
306
|
-
<td>${toolBadge(row.tool_name)}</td>
|
|
385
|
+
<td>${toolBadge(row.tool_name)}${obsLabel}</td>
|
|
307
386
|
<td>${decisionBadge(row)}</td>
|
|
308
387
|
<td>${getTargetDisplay(row)}</td>
|
|
309
388
|
<td>${statusLabel(row.status)}${row.error_message ? `<br><span style="color:#666;font-size:11px">${row.error_message.slice(0,80)}</span>` : ''}</td>
|
|
@@ -312,6 +391,11 @@
|
|
|
312
391
|
</tr>`;
|
|
313
392
|
});
|
|
314
393
|
|
|
394
|
+
if (!rows.length) {
|
|
395
|
+
tbody.innerHTML = '<tr><td colspan="7" class="empty">No actions match current filter.</td></tr>';
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
315
399
|
tbody.innerHTML = rows.join('');
|
|
316
400
|
|
|
317
401
|
tbody.querySelectorAll('button.rollback').forEach(btn => {
|
|
@@ -340,15 +424,34 @@
|
|
|
340
424
|
|
|
341
425
|
async function refresh() {
|
|
342
426
|
try {
|
|
343
|
-
|
|
344
|
-
|
|
427
|
+
// Build query string from filter values
|
|
428
|
+
const status = document.getElementById('filter-status').value;
|
|
429
|
+
const tool = document.getElementById('filter-tool').value;
|
|
430
|
+
const search = document.getElementById('filter-search').value;
|
|
431
|
+
|
|
432
|
+
const params = new URLSearchParams();
|
|
433
|
+
if (status) params.append('status', status);
|
|
434
|
+
if (tool) params.append('tool_name', tool);
|
|
435
|
+
if (search) params.append('search', search);
|
|
436
|
+
params.append('page', '1');
|
|
437
|
+
params.append('limit', '100');
|
|
438
|
+
|
|
439
|
+
// Fetch paginated data
|
|
440
|
+
const [paginationRes, countRes] = await Promise.all([
|
|
441
|
+
fetch(`/api/actions/paginated?${params}`),
|
|
345
442
|
fetch('/api/actions?count=true'),
|
|
346
443
|
]);
|
|
347
|
-
|
|
444
|
+
|
|
445
|
+
const pagination = await paginationRes.json();
|
|
348
446
|
const { count } = await countRes.json();
|
|
447
|
+
|
|
448
|
+
const actions = pagination.actions || [];
|
|
349
449
|
renderActions(Array.isArray(actions) ? actions : []);
|
|
350
|
-
|
|
450
|
+
|
|
451
|
+
const display = status || tool ? `${actions.length} filtered` : `${actions.length} action(s)`;
|
|
452
|
+
document.getElementById('count-display').textContent = display;
|
|
351
453
|
document.getElementById('refresh-display').textContent = 'last refresh: ' + new Date().toLocaleTimeString();
|
|
454
|
+
|
|
352
455
|
const badge = document.getElementById('pending-badge');
|
|
353
456
|
badge.style.display = count > 0 ? 'inline' : 'none';
|
|
354
457
|
badge.textContent = `[${count} pending]`;
|
|
@@ -424,6 +527,64 @@
|
|
|
424
527
|
refresh();
|
|
425
528
|
loadProjectName();
|
|
426
529
|
setInterval(refresh, 3000);
|
|
530
|
+
|
|
531
|
+
// Filter event listeners
|
|
532
|
+
document.getElementById('filter-execution').addEventListener('change', refresh);
|
|
533
|
+
document.getElementById('filter-observation').addEventListener('change', refresh);
|
|
534
|
+
|
|
535
|
+
// Phase 3: New filter listeners
|
|
536
|
+
document.getElementById('filter-status').addEventListener('change', refresh);
|
|
537
|
+
document.getElementById('filter-tool').addEventListener('change', refresh);
|
|
538
|
+
document.getElementById('filter-search').addEventListener('input', () => {
|
|
539
|
+
clearTimeout(window.filterSearchTimeout);
|
|
540
|
+
window.filterSearchTimeout = setTimeout(refresh, 500);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// Phase 2: Hub navigation sidebar
|
|
544
|
+
function loadHubNav() {
|
|
545
|
+
fetch('/api/hub/projects')
|
|
546
|
+
.then(r => r.json())
|
|
547
|
+
.then(projects => {
|
|
548
|
+
const list = document.getElementById('project-list');
|
|
549
|
+
if (!projects || projects.length === 0) {
|
|
550
|
+
list.innerHTML = '<li style="color:#555;padding:6px 4px">no projects</li>';
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const current = document.getElementById('project-name').textContent.split('—')[1]?.trim();
|
|
555
|
+
list.innerHTML = projects.map(p => {
|
|
556
|
+
const isCurrent = current && p.projectName.includes(current);
|
|
557
|
+
const statusClass = p.status === 'running' ? 'running' : p.status === 'paused' ? 'paused' : 'stopped';
|
|
558
|
+
const activeClass = isCurrent ? ' current' : '';
|
|
559
|
+
const icon = p.status === 'running' ? '🟢' : p.status === 'paused' ? '⏸️ ' : '🔴';
|
|
560
|
+
return `<li class="project-item ${statusClass}${activeClass}" data-port="${p.port}">${icon} ${p.id}<span class="project-port">${p.port}</span></li>`;
|
|
561
|
+
}).join('');
|
|
562
|
+
|
|
563
|
+
// Add click handlers
|
|
564
|
+
list.querySelectorAll('.project-item').forEach(item => {
|
|
565
|
+
item.addEventListener('click', () => {
|
|
566
|
+
const port = item.dataset.port;
|
|
567
|
+
window.location.href = `http://localhost:${port}`;
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
})
|
|
571
|
+
.catch(() => {
|
|
572
|
+
// Hub projects not available — this is Phase 2 feature (optional)
|
|
573
|
+
document.getElementById('hub-toggle').style.display = 'none';
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Hub toggle button
|
|
578
|
+
document.getElementById('hub-toggle').addEventListener('click', () => {
|
|
579
|
+
const nav = document.getElementById('hub-nav');
|
|
580
|
+
const content = document.getElementById('main-content');
|
|
581
|
+
nav.classList.toggle('open');
|
|
582
|
+
content.classList.toggle('with-nav');
|
|
583
|
+
loadHubNav();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Load hub projects on startup (lazy)
|
|
587
|
+
setTimeout(loadHubNav, 2000);
|
|
427
588
|
</script>
|
|
428
589
|
</body>
|
|
429
590
|
</html>
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>waymark — agent action viewer</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
8
|
+
body { font-family: monospace; font-size: 13px; background: #0f0f0f; color: #d0d0d0; padding: 16px; }
|
|
9
|
+
h1 { font-size: 18px; margin-bottom: 4px; color: #fff; }
|
|
10
|
+
.subtitle { color: #555; margin-bottom: 16px; font-size: 12px; }
|
|
11
|
+
.meta { display: flex; gap: 16px; margin-bottom: 12px; color: #666; font-size: 11px; }
|
|
12
|
+
table { width: 100%; border-collapse: collapse; }
|
|
13
|
+
th { text-align: left; padding: 6px 8px; background: #1a1a1a; color: #888; font-weight: normal; border-bottom: 1px solid #333; }
|
|
14
|
+
td { padding: 6px 8px; border-bottom: 1px solid #1e1e1e; vertical-align: top; }
|
|
15
|
+
tr:hover td { background: #161616; }
|
|
16
|
+
.badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: bold; }
|
|
17
|
+
.badge-write_file { background: #7c3a00; color: #ffb347; }
|
|
18
|
+
.badge-read_file { background: #002f5c; color: #5ab4ff; }
|
|
19
|
+
.badge-bash { background: #222; color: #aaa; border: 1px solid #444; }
|
|
20
|
+
.decision-block { background: #5c0000; color: #ff6b6b; }
|
|
21
|
+
.decision-pending { background: #4a3800; color: #ffd166; }
|
|
22
|
+
.decision-rejected { background: #3a0000; color: #ff9999; }
|
|
23
|
+
.status-success { color: #4caf50; }
|
|
24
|
+
.status-error { color: #f44336; }
|
|
25
|
+
.status-pending { color: #ffb347; }
|
|
26
|
+
.status-blocked { color: #ff6b6b; }
|
|
27
|
+
.path { color: #888; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
|
28
|
+
button.rollback {
|
|
29
|
+
background: #3a1f00; border: 1px solid #7c3a00; color: #ffb347;
|
|
30
|
+
padding: 2px 8px; cursor: pointer; font-size: 11px; font-family: monospace;
|
|
31
|
+
border-radius: 3px;
|
|
32
|
+
}
|
|
33
|
+
button.rollback:hover { background: #5c3000; }
|
|
34
|
+
button.rollback:disabled { opacity: 0.4; cursor: default; }
|
|
35
|
+
.msg-ok { color: #4caf50; margin-left: 6px; }
|
|
36
|
+
.msg-err { color: #f44336; margin-left: 6px; }
|
|
37
|
+
.rolled-back { color: #555; font-size: 11px; }
|
|
38
|
+
#status-bar { position: fixed; bottom: 8px; right: 16px; color: #333; font-size: 11px; }
|
|
39
|
+
.empty { padding: 32px; text-align: center; color: #444; }
|
|
40
|
+
|
|
41
|
+
/* Config viewer */
|
|
42
|
+
#config-section { margin-top: 24px; }
|
|
43
|
+
#config-toggle {
|
|
44
|
+
background: none; border: 1px solid #333; color: #888; padding: 4px 10px;
|
|
45
|
+
cursor: pointer; font-family: monospace; font-size: 12px; border-radius: 3px;
|
|
46
|
+
}
|
|
47
|
+
#config-toggle:hover { border-color: #555; color: #aaa; }
|
|
48
|
+
#config-content { display: none; margin-top: 12px; padding: 12px; background: #141414; border: 1px solid #222; border-radius: 4px; }
|
|
49
|
+
#config-content h3 { color: #666; font-size: 11px; font-weight: normal; margin-bottom: 10px; }
|
|
50
|
+
.policy-group { margin-bottom: 12px; }
|
|
51
|
+
.policy-group h4 { font-size: 11px; color: #555; margin-bottom: 4px; font-weight: normal; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
52
|
+
.policy-group ul { list-style: none; padding: 0; }
|
|
53
|
+
.policy-group li { padding: 2px 0; font-size: 12px; }
|
|
54
|
+
.policy-allow { color: #4caf50; }
|
|
55
|
+
.policy-block { color: #ff6b6b; }
|
|
56
|
+
.policy-pending { color: #ffd166; }
|
|
57
|
+
|
|
58
|
+
/* v3: approval flow */
|
|
59
|
+
.status-rejected { color: #e57373; }
|
|
60
|
+
.status-approved { color: #4caf50; }
|
|
61
|
+
.btn-approve {
|
|
62
|
+
background: #003a1a; border: 1px solid #2e7d32; color: #81c784;
|
|
63
|
+
padding: 2px 8px; cursor: pointer; font-size: 11px; font-family: monospace;
|
|
64
|
+
border-radius: 3px; margin-right: 4px;
|
|
65
|
+
}
|
|
66
|
+
.btn-approve:hover { background: #1b5e20; }
|
|
67
|
+
.btn-approve:disabled { opacity: 0.4; cursor: default; }
|
|
68
|
+
.btn-reject {
|
|
69
|
+
background: #3a0000; border: 1px solid #7f0000; color: #ef9a9a;
|
|
70
|
+
padding: 2px 8px; cursor: pointer; font-size: 11px; font-family: monospace;
|
|
71
|
+
border-radius: 3px;
|
|
72
|
+
}
|
|
73
|
+
.btn-reject:hover { background: #5c0000; }
|
|
74
|
+
.btn-reject:disabled { opacity: 0.4; cursor: default; }
|
|
75
|
+
.reject-input {
|
|
76
|
+
background: #1a1a1a; border: 1px solid #444; color: #ccc;
|
|
77
|
+
padding: 2px 6px; font-family: monospace; font-size: 11px;
|
|
78
|
+
border-radius: 3px; width: 120px; margin-right: 4px;
|
|
79
|
+
}
|
|
80
|
+
#pending-badge {
|
|
81
|
+
background: #7f0000; color: #ef9a9a; border-radius: 3px;
|
|
82
|
+
padding: 1px 6px; font-size: 13px; margin-left: 8px;
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
87
|
+
<h1>waymark <span id="project-name" style="color:#555"></span><span id="pending-badge" style="display:none"></span></h1>
|
|
88
|
+
<p class="subtitle">uglybugly agent action viewer — intercepts and logs MCP tool calls</p>
|
|
89
|
+
<div class="meta">
|
|
90
|
+
<span id="count-display">loading...</span>
|
|
91
|
+
<span id="refresh-display"></span>
|
|
92
|
+
</div>
|
|
93
|
+
<table>
|
|
94
|
+
<thead>
|
|
95
|
+
<tr>
|
|
96
|
+
<th>Time</th>
|
|
97
|
+
<th>Tool</th>
|
|
98
|
+
<th>Decision</th>
|
|
99
|
+
<th>Target / Command</th>
|
|
100
|
+
<th>Status</th>
|
|
101
|
+
<th>Stdout</th>
|
|
102
|
+
<th>Action</th>
|
|
103
|
+
</tr>
|
|
104
|
+
</thead>
|
|
105
|
+
<tbody id="action-tbody">
|
|
106
|
+
<tr><td colspan="7" class="empty">loading...</td></tr>
|
|
107
|
+
</tbody>
|
|
108
|
+
</table>
|
|
109
|
+
|
|
110
|
+
<div id="config-section">
|
|
111
|
+
<button id="config-toggle">▶ Current Policy</button>
|
|
112
|
+
<div id="config-content">
|
|
113
|
+
<h3>waymark.config.json</h3>
|
|
114
|
+
<div id="config-body">loading...</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div id="status-bar">auto-refresh every 3s</div>
|
|
119
|
+
|
|
120
|
+
<script>
|
|
121
|
+
function timeAgo(dateStr) {
|
|
122
|
+
const now = new Date();
|
|
123
|
+
const then = new Date(dateStr + (dateStr.includes('Z') ? '' : 'Z'));
|
|
124
|
+
const diff = Math.floor((now - then) / 1000);
|
|
125
|
+
if (diff < 5) return 'just now';
|
|
126
|
+
if (diff < 60) return diff + 's ago';
|
|
127
|
+
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
128
|
+
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
|
129
|
+
return Math.floor(diff / 86400) + 'd ago';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function toolBadge(name) {
|
|
133
|
+
return `<span class="badge badge-${name}">${name}</span>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function statusLabel(status) {
|
|
137
|
+
return `<span class="status-${status}">${status}</span>`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function decisionBadge(row) {
|
|
141
|
+
const d = row.decision || 'allow';
|
|
142
|
+
if (d === 'block') {
|
|
143
|
+
const tip = row.policy_reason ? row.policy_reason.replace(/"/g, '"') : '';
|
|
144
|
+
return `<span class="badge decision-block" title="${tip}">blocked</span>`;
|
|
145
|
+
}
|
|
146
|
+
if (d === 'pending') {
|
|
147
|
+
const tip = row.policy_reason ? row.policy_reason.replace(/"/g, '"') : '';
|
|
148
|
+
return `<span class="badge decision-pending" title="${tip}">pending</span>`;
|
|
149
|
+
}
|
|
150
|
+
if (d === 'rejected') {
|
|
151
|
+
const tip = row.rejected_reason ? row.rejected_reason.replace(/"/g, '"') : '';
|
|
152
|
+
return `<span class="badge decision-rejected" title="${tip}">rejected</span>`;
|
|
153
|
+
}
|
|
154
|
+
return `<span style="color:#333">—</span>`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function truncatePath(p) {
|
|
158
|
+
if (!p) return '<span style="color:#444">—</span>';
|
|
159
|
+
const parts = p.replace(/\\/g, '/').split('/');
|
|
160
|
+
const display = parts.length > 3 ? '…/' + parts.slice(-3).join('/') : p;
|
|
161
|
+
return `<span class="path" title="${p}">${display}</span>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getTargetDisplay(row) {
|
|
165
|
+
if (row.tool_name === 'bash') {
|
|
166
|
+
const cmd = JSON.parse(row.input_payload).command || '';
|
|
167
|
+
const short = cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd;
|
|
168
|
+
return `<span class="path" title="${cmd.replace(/"/g, '"')}">${short}</span>`;
|
|
169
|
+
}
|
|
170
|
+
return truncatePath(row.target_path);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function doRollback(actionId, btn, msgSpan, isNewFile) {
|
|
174
|
+
btn.disabled = true;
|
|
175
|
+
btn.textContent = isNewFile ? 'deleting...' : 'rolling back...';
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetch(`/api/actions/${actionId}/rollback`, { method: 'POST' });
|
|
178
|
+
const data = await res.json();
|
|
179
|
+
if (res.ok) {
|
|
180
|
+
msgSpan.className = 'msg-ok';
|
|
181
|
+
msgSpan.textContent = data.action === 'deleted' ? '✓ deleted' : '✓ restored';
|
|
182
|
+
btn.textContent = data.action === 'deleted' ? 'deleted' : 'rolled back';
|
|
183
|
+
} else {
|
|
184
|
+
msgSpan.className = 'msg-err';
|
|
185
|
+
msgSpan.textContent = '✗ ' + (data.error || 'failed');
|
|
186
|
+
btn.disabled = false;
|
|
187
|
+
btn.textContent = isNewFile ? 'delete (new file)' : 'rollback';
|
|
188
|
+
}
|
|
189
|
+
} catch (e) {
|
|
190
|
+
msgSpan.className = 'msg-err';
|
|
191
|
+
msgSpan.textContent = '✗ network error';
|
|
192
|
+
btn.disabled = false;
|
|
193
|
+
btn.textContent = isNewFile ? 'delete (new file)' : 'rollback';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function doApprove(actionId, btn, msgSpan) {
|
|
198
|
+
btn.disabled = true;
|
|
199
|
+
btn.textContent = 'approving...';
|
|
200
|
+
try {
|
|
201
|
+
const res = await fetch(`/api/actions/${actionId}/approve`, { method: 'POST' });
|
|
202
|
+
const data = await res.json();
|
|
203
|
+
if (res.ok) {
|
|
204
|
+
msgSpan.className = 'msg-ok';
|
|
205
|
+
msgSpan.textContent = '✓ approved';
|
|
206
|
+
// Full refresh to get updated row
|
|
207
|
+
setTimeout(refresh, 300);
|
|
208
|
+
} else {
|
|
209
|
+
msgSpan.className = 'msg-err';
|
|
210
|
+
msgSpan.textContent = '✗ ' + (data.error || 'failed');
|
|
211
|
+
btn.disabled = false;
|
|
212
|
+
btn.textContent = '✅ Approve';
|
|
213
|
+
}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
msgSpan.className = 'msg-err';
|
|
216
|
+
msgSpan.textContent = '✗ network error';
|
|
217
|
+
btn.disabled = false;
|
|
218
|
+
btn.textContent = '✅ Approve';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function doReject(actionId, btn, rejectBtn, msgSpan) {
|
|
223
|
+
// Show inline input for reason
|
|
224
|
+
rejectBtn.disabled = true;
|
|
225
|
+
const inputEl = document.createElement('input');
|
|
226
|
+
inputEl.className = 'reject-input';
|
|
227
|
+
inputEl.type = 'text';
|
|
228
|
+
inputEl.value = 'Not approved';
|
|
229
|
+
inputEl.placeholder = 'reason...';
|
|
230
|
+
const confirmBtn = document.createElement('button');
|
|
231
|
+
confirmBtn.className = 'btn-reject';
|
|
232
|
+
confirmBtn.textContent = 'Confirm';
|
|
233
|
+
rejectBtn.replaceWith(inputEl);
|
|
234
|
+
msgSpan.parentNode.insertBefore(confirmBtn, msgSpan);
|
|
235
|
+
|
|
236
|
+
confirmBtn.addEventListener('click', async () => {
|
|
237
|
+
confirmBtn.disabled = true;
|
|
238
|
+
inputEl.disabled = true;
|
|
239
|
+
confirmBtn.textContent = 'rejecting...';
|
|
240
|
+
try {
|
|
241
|
+
const res = await fetch(`/api/actions/${actionId}/reject`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
244
|
+
body: JSON.stringify({ reason: inputEl.value || 'Not approved' }),
|
|
245
|
+
});
|
|
246
|
+
const data = await res.json();
|
|
247
|
+
if (res.ok) {
|
|
248
|
+
msgSpan.className = 'msg-ok';
|
|
249
|
+
msgSpan.textContent = '✓ rejected';
|
|
250
|
+
setTimeout(refresh, 300);
|
|
251
|
+
} else {
|
|
252
|
+
msgSpan.className = 'msg-err';
|
|
253
|
+
msgSpan.textContent = '✗ ' + (data.error || 'failed');
|
|
254
|
+
confirmBtn.disabled = false;
|
|
255
|
+
confirmBtn.textContent = 'Confirm';
|
|
256
|
+
}
|
|
257
|
+
} catch (e) {
|
|
258
|
+
msgSpan.className = 'msg-err';
|
|
259
|
+
msgSpan.textContent = '✗ network error';
|
|
260
|
+
confirmBtn.disabled = false;
|
|
261
|
+
confirmBtn.textContent = 'Confirm';
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function renderActions(actions) {
|
|
267
|
+
const tbody = document.getElementById('action-tbody');
|
|
268
|
+
if (!actions.length) {
|
|
269
|
+
tbody.innerHTML = '<tr><td colspan="7" class="empty">No actions yet. Connect Claude Code to the waymark MCP server and run some tools.</td></tr>';
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const rows = actions.map(row => {
|
|
274
|
+
const isWriteFile = row.tool_name === 'write_file';
|
|
275
|
+
const isNewFile = isWriteFile && !row.before_snapshot;
|
|
276
|
+
const isPending = row.decision === 'pending' && row.status === 'pending';
|
|
277
|
+
const isApproved = row.status === 'success' && row.approved_by;
|
|
278
|
+
const isRejected = row.status === 'rejected';
|
|
279
|
+
const isBlocked = row.decision === 'block';
|
|
280
|
+
const canRollback = isWriteFile && !row.rolled_back && !isBlocked && !isPending && !isApproved && row.status === 'success' && !row.approved_by;
|
|
281
|
+
|
|
282
|
+
let actionCell;
|
|
283
|
+
if (isPending) {
|
|
284
|
+
actionCell = `<button class="btn-approve" data-id="${row.action_id}">✅ Approve</button><button class="btn-reject" data-id="${row.action_id}">❌ Reject</button><span class="action-msg"></span>`;
|
|
285
|
+
} else if (isApproved) {
|
|
286
|
+
const tip = `by ${row.approved_by}${row.approved_at ? ' at ' + row.approved_at : ''}`.replace(/"/g, '"');
|
|
287
|
+
actionCell = `<span class="status-approved" title="${tip}">✅ Approved</span>`;
|
|
288
|
+
} else if (isRejected) {
|
|
289
|
+
const tip = (row.rejected_reason || '').replace(/"/g, '"');
|
|
290
|
+
actionCell = `<span class="status-rejected" title="${tip}">❌ Rejected</span>`;
|
|
291
|
+
} else if (row.rolled_back) {
|
|
292
|
+
actionCell = `<span class="rolled-back">↩ ${isNewFile ? 'deleted' : 'rolled back'}</span>`;
|
|
293
|
+
} else if (canRollback) {
|
|
294
|
+
const btnLabel = isNewFile ? 'delete (new file)' : 'rollback';
|
|
295
|
+
actionCell = `<button class="rollback" data-id="${row.action_id}" data-newfile="${isNewFile}">${btnLabel}</button><span class="rollback-msg"></span>`;
|
|
296
|
+
} else {
|
|
297
|
+
actionCell = `<span style="color:#333">—</span>`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const stdoutPreview = row.tool_name === 'bash' && row.stdout
|
|
301
|
+
? `<span style="color:#666;font-size:11px">${row.stdout.slice(0, 100).replace(/\n/g, '↵')}</span>`
|
|
302
|
+
: `<span style="color:#333">—</span>`;
|
|
303
|
+
|
|
304
|
+
return `<tr>
|
|
305
|
+
<td style="white-space:nowrap;color:#555">${timeAgo(row.created_at)}</td>
|
|
306
|
+
<td>${toolBadge(row.tool_name)}</td>
|
|
307
|
+
<td>${decisionBadge(row)}</td>
|
|
308
|
+
<td>${getTargetDisplay(row)}</td>
|
|
309
|
+
<td>${statusLabel(row.status)}${row.error_message ? `<br><span style="color:#666;font-size:11px">${row.error_message.slice(0,80)}</span>` : ''}</td>
|
|
310
|
+
<td>${stdoutPreview}</td>
|
|
311
|
+
<td style="white-space:nowrap">${actionCell}</td>
|
|
312
|
+
</tr>`;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
tbody.innerHTML = rows.join('');
|
|
316
|
+
|
|
317
|
+
tbody.querySelectorAll('button.rollback').forEach(btn => {
|
|
318
|
+
btn.addEventListener('click', () => {
|
|
319
|
+
const msgSpan = btn.nextElementSibling;
|
|
320
|
+
doRollback(btn.dataset.id, btn, msgSpan, btn.dataset.newfile === 'true');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
tbody.querySelectorAll('button.btn-approve').forEach(btn => {
|
|
325
|
+
btn.addEventListener('click', () => {
|
|
326
|
+
const msgSpan = btn.parentElement.querySelector('.action-msg');
|
|
327
|
+
doApprove(btn.dataset.id, btn, msgSpan);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
tbody.querySelectorAll('button.btn-reject').forEach(btn => {
|
|
332
|
+
btn.addEventListener('click', () => {
|
|
333
|
+
const approveBtn = btn.previousElementSibling;
|
|
334
|
+
const msgSpan = btn.nextElementSibling;
|
|
335
|
+
approveBtn.disabled = true;
|
|
336
|
+
doReject(btn.dataset.id, approveBtn, btn, msgSpan);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function refresh() {
|
|
342
|
+
try {
|
|
343
|
+
const [actionsRes, countRes] = await Promise.all([
|
|
344
|
+
fetch('/api/actions'),
|
|
345
|
+
fetch('/api/actions?count=true'),
|
|
346
|
+
]);
|
|
347
|
+
const actions = await actionsRes.json();
|
|
348
|
+
const { count } = await countRes.json();
|
|
349
|
+
renderActions(Array.isArray(actions) ? actions : []);
|
|
350
|
+
document.getElementById('count-display').textContent = `${actions.length} action(s)`;
|
|
351
|
+
document.getElementById('refresh-display').textContent = 'last refresh: ' + new Date().toLocaleTimeString();
|
|
352
|
+
const badge = document.getElementById('pending-badge');
|
|
353
|
+
badge.style.display = count > 0 ? 'inline' : 'none';
|
|
354
|
+
badge.textContent = `[${count} pending]`;
|
|
355
|
+
} catch (e) {
|
|
356
|
+
document.getElementById('count-display').textContent = 'error fetching actions';
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Config viewer toggle
|
|
361
|
+
const configToggle = document.getElementById('config-toggle');
|
|
362
|
+
const configContent = document.getElementById('config-content');
|
|
363
|
+
let configOpen = false;
|
|
364
|
+
|
|
365
|
+
configToggle.addEventListener('click', async () => {
|
|
366
|
+
configOpen = !configOpen;
|
|
367
|
+
configContent.style.display = configOpen ? 'block' : 'none';
|
|
368
|
+
configToggle.textContent = (configOpen ? '▼' : '▶') + ' Current Policy';
|
|
369
|
+
if (configOpen) await loadConfigView();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
async function loadConfigView() {
|
|
373
|
+
const body = document.getElementById('config-body');
|
|
374
|
+
try {
|
|
375
|
+
const res = await fetch('/api/config');
|
|
376
|
+
const cfg = await res.json();
|
|
377
|
+
const p = cfg.policies || {};
|
|
378
|
+
|
|
379
|
+
function renderList(items, cls) {
|
|
380
|
+
if (!items || !items.length) return '<li style="color:#444">none</li>';
|
|
381
|
+
return items.map(i => {
|
|
382
|
+
if (i.startsWith('regex:')) {
|
|
383
|
+
const pat = i.slice(6);
|
|
384
|
+
return `<li class="${cls}">${pat} <em style="color:#555;font-style:italic">[pattern]</em></li>`;
|
|
385
|
+
}
|
|
386
|
+
return `<li class="${cls}">${i}</li>`;
|
|
387
|
+
}).join('');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
body.innerHTML = `
|
|
391
|
+
<div class="policy-group">
|
|
392
|
+
<h4>Allowed Paths</h4>
|
|
393
|
+
<ul>${renderList(p.allowedPaths, 'policy-allow')}</ul>
|
|
394
|
+
</div>
|
|
395
|
+
<div class="policy-group">
|
|
396
|
+
<h4>Blocked Paths</h4>
|
|
397
|
+
<ul>${renderList(p.blockedPaths, 'policy-block')}</ul>
|
|
398
|
+
</div>
|
|
399
|
+
<div class="policy-group">
|
|
400
|
+
<h4>Blocked Commands</h4>
|
|
401
|
+
<ul>${renderList(p.blockedCommands, 'policy-block')}</ul>
|
|
402
|
+
</div>
|
|
403
|
+
<div class="policy-group">
|
|
404
|
+
<h4>Require Approval</h4>
|
|
405
|
+
<ul>${renderList(p.requireApproval, 'policy-pending')}</ul>
|
|
406
|
+
</div>
|
|
407
|
+
`;
|
|
408
|
+
} catch (e) {
|
|
409
|
+
body.textContent = 'Error loading config';
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function loadProjectName() {
|
|
414
|
+
try {
|
|
415
|
+
const res = await fetch('/api/project');
|
|
416
|
+
const data = await res.json();
|
|
417
|
+
if (data.projectName) {
|
|
418
|
+
document.getElementById('project-name').textContent = '— ' + data.projectName;
|
|
419
|
+
}
|
|
420
|
+
} catch (e) { /* silent — project name is cosmetic */ }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Initial load + interval
|
|
424
|
+
refresh();
|
|
425
|
+
loadProjectName();
|
|
426
|
+
setInterval(refresh, 3000);
|
|
427
|
+
</script>
|
|
428
|
+
</body>
|
|
429
|
+
</html>
|