mercury-agent 0.9.0 → 0.9.1
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/package.json +1 -1
- package/src/core/permissions.ts +44 -2
- package/src/core/routes/chat.ts +8 -1
- package/src/core/routes/dashboard.ts +17 -7
- package/src/core/routes/tasks.ts +11 -5
- package/src/core/task-scheduler.ts +115 -41
- package/src/dashboard/index.html +64 -0
- package/src/storage/db.ts +16 -1
package/package.json
CHANGED
package/src/core/permissions.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { logger } from "../logger.js";
|
|
1
2
|
import type { Db } from "../storage/db.js";
|
|
2
3
|
import { matchesConfiguredId } from "./global-admin.js";
|
|
3
4
|
|
|
@@ -139,6 +140,36 @@ function toPermissionSet(list: string[]): Set<string> {
|
|
|
139
140
|
*/
|
|
140
141
|
export const seededSpaces = new Set<string>();
|
|
141
142
|
|
|
143
|
+
/**
|
|
144
|
+
* (space, caller) pairs whose config-admin re-promotion was already logged —
|
|
145
|
+
* once per process, not per message (resolveRole runs on every message).
|
|
146
|
+
* Exported for test isolation (tests should clear this in beforeEach).
|
|
147
|
+
*/
|
|
148
|
+
export const warnedRepromotions = new Set<string>();
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A stored non-admin role was overridden back to admin because the caller is
|
|
152
|
+
* listed in config.admins. The override is deliberate (config admins are
|
|
153
|
+
* always admins), but it must be visible: an operator's explicit demotion
|
|
154
|
+
* otherwise silently doesn't hold. Deduped once per (space, caller) per
|
|
155
|
+
* process.
|
|
156
|
+
*/
|
|
157
|
+
function warnConfigAdminRepromotion(
|
|
158
|
+
spaceId: string,
|
|
159
|
+
callerId: string,
|
|
160
|
+
source: "re-seed" | "self-heal",
|
|
161
|
+
): void {
|
|
162
|
+
// NUL separator: caller ids contain ":" (e.g. "whatsapp:..."), so a ":"
|
|
163
|
+
// join could collide distinct (space, caller) pairs.
|
|
164
|
+
const key = `${spaceId}\u0000${callerId}`;
|
|
165
|
+
if (warnedRepromotions.has(key)) return;
|
|
166
|
+
warnedRepromotions.add(key);
|
|
167
|
+
logger.warn(
|
|
168
|
+
"Config admin re-promoted: stored role overridden — remove from config.admins to demote",
|
|
169
|
+
{ spaceId, callerId, source },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
142
173
|
// ---------------------------------------------------------------------------
|
|
143
174
|
// System callers
|
|
144
175
|
// ---------------------------------------------------------------------------
|
|
@@ -252,13 +283,19 @@ export function resolveRole(
|
|
|
252
283
|
if (isSystemCaller(platformUserId)) return "system";
|
|
253
284
|
|
|
254
285
|
if (seededAdmins.length > 0 && !seededSpaces.has(spaceId)) {
|
|
255
|
-
db.seedAdmins(spaceId, seededAdmins)
|
|
286
|
+
for (const id of db.seedAdmins(spaceId, seededAdmins)) {
|
|
287
|
+
warnConfigAdminRepromotion(spaceId, id, "re-seed");
|
|
288
|
+
}
|
|
256
289
|
seededSpaces.add(spaceId);
|
|
257
290
|
}
|
|
258
291
|
|
|
292
|
+
// Read before upsertMember, which inserts a fresh "member" row on first
|
|
293
|
+
// contact — a null here distinguishes "never seen" from "operator demoted".
|
|
294
|
+
const storedRole = db.getRole(spaceId, platformUserId);
|
|
295
|
+
|
|
259
296
|
db.upsertMember(spaceId, platformUserId, displayName);
|
|
260
297
|
|
|
261
|
-
const role =
|
|
298
|
+
const role = storedRole ?? "member";
|
|
262
299
|
|
|
263
300
|
// Seeded rows are keyed by the raw config string, which may differ from the
|
|
264
301
|
// canonical caller id (format looseness, WhatsApp LID vs phone JID). When the
|
|
@@ -269,6 +306,11 @@ export function resolveRole(
|
|
|
269
306
|
role === "member" &&
|
|
270
307
|
matchesConfiguredId(platformUserId, seededAdmins, db)
|
|
271
308
|
) {
|
|
309
|
+
// Warn only when a member row pre-existed (a stored demotion being
|
|
310
|
+
// overridden) — a first-contact self-heal has nothing to override.
|
|
311
|
+
if (storedRole === "member") {
|
|
312
|
+
warnConfigAdminRepromotion(spaceId, platformUserId, "self-heal");
|
|
313
|
+
}
|
|
272
314
|
db.setRole(spaceId, platformUserId, "admin", "seed");
|
|
273
315
|
return "admin";
|
|
274
316
|
}
|
package/src/core/routes/chat.ts
CHANGED
|
@@ -147,7 +147,14 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
if (authenticated) {
|
|
150
|
-
|
|
150
|
+
// Fires at most once per actual override: after the re-promotion the
|
|
151
|
+
// row is admin again, so later requests return an empty list.
|
|
152
|
+
for (const id of core.db.seedAdmins(spaceId, [callerId])) {
|
|
153
|
+
logger.warn(
|
|
154
|
+
"Authenticated chat caller re-promoted: stored role overridden",
|
|
155
|
+
{ spaceId, callerId: id },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
const ingress: IngressMessage = {
|
|
@@ -993,9 +993,11 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
993
993
|
<span class="truncate">${escapeHtml(truncate(t.prompt, 25))}</span>
|
|
994
994
|
<span class="muted">${formatFutureTime(t.nextRunAt)}</span>
|
|
995
995
|
${t.silent === 1 ? '<span class="badge muted">silent</span>' : '<span class="badge">chat</span>'}
|
|
996
|
-
<button class="btn btn-sm"
|
|
997
|
-
hx-post="/dashboard/api/tasks/${t.id}/run"
|
|
996
|
+
<button class="btn btn-sm"
|
|
997
|
+
hx-post="/dashboard/api/tasks/${t.id}/run"
|
|
998
998
|
hx-swap="none"
|
|
999
|
+
hx-sync="this:drop"
|
|
1000
|
+
hx-disabled-elt="this"
|
|
999
1001
|
title="Run now">▶</button>
|
|
1000
1002
|
</div>
|
|
1001
1003
|
`,
|
|
@@ -1495,7 +1497,7 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
1495
1497
|
<td><span class="badge ${t.silent === 1 ? "muted" : ""}">${t.silent === 1 ? "silent" : "chat"}</span></td>
|
|
1496
1498
|
<td><span class="badge ${t.active ? "green" : ""}">${t.active ? "active" : "paused"}</span></td>
|
|
1497
1499
|
<td class="actions">
|
|
1498
|
-
<button class="btn btn-sm" hx-post="/dashboard/api/tasks/${t.id}/run" hx-swap="none" title="Run now">▶</button>
|
|
1500
|
+
<button class="btn btn-sm" hx-post="/dashboard/api/tasks/${t.id}/run" hx-swap="none" hx-sync="this:drop" hx-disabled-elt="this" title="Run now">▶</button>
|
|
1499
1501
|
${
|
|
1500
1502
|
t.active
|
|
1501
1503
|
? `<button class="btn btn-sm" hx-post="/dashboard/api/tasks/${t.id}/pause" hx-swap="none" title="Pause">⏸</button>`
|
|
@@ -2472,7 +2474,7 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
2472
2474
|
|
|
2473
2475
|
// ─── Dashboard Actions (no auth required, admin-only UI) ────────────────
|
|
2474
2476
|
|
|
2475
|
-
app.post("/api/tasks/:id/run",
|
|
2477
|
+
app.post("/api/tasks/:id/run", (c) => {
|
|
2476
2478
|
const taskId = Number.parseInt(c.req.param("id"), 10);
|
|
2477
2479
|
const task = core.db.listTasks().find((t) => t.id === taskId);
|
|
2478
2480
|
|
|
@@ -2480,12 +2482,20 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
2480
2482
|
return c.json({ error: "Task not found" }, 404);
|
|
2481
2483
|
}
|
|
2482
2484
|
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
+
// Returns as soon as the run starts — never awaits the agent run, which
|
|
2486
|
+
// takes minutes and would leave the ▶ button dead for its whole duration.
|
|
2487
|
+
const result = core.scheduler.triggerTask(taskId);
|
|
2488
|
+
if (result === "not-found") {
|
|
2485
2489
|
return c.json({ error: "Task not found or inactive" }, 400);
|
|
2486
2490
|
}
|
|
2491
|
+
if (result === "already-running") {
|
|
2492
|
+
return c.json({ error: `Task #${taskId} is already running` }, 409);
|
|
2493
|
+
}
|
|
2494
|
+
if (result === "unavailable") {
|
|
2495
|
+
return c.json({ error: "Scheduler is not running" }, 503);
|
|
2496
|
+
}
|
|
2487
2497
|
|
|
2488
|
-
return c.json({ ok: true });
|
|
2498
|
+
return c.json({ ok: true, started: true });
|
|
2489
2499
|
});
|
|
2490
2500
|
|
|
2491
2501
|
app.post("/api/tasks/:id/pause", (c) => {
|
package/src/core/routes/tasks.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { CronExpressionParser } from "cron-parser";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
-
import { logger } from "../../logger.js";
|
|
4
3
|
import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
5
4
|
|
|
6
5
|
export const tasks = new Hono<Env>();
|
|
@@ -161,10 +160,17 @@ tasks.post("/:id/run", (c) => {
|
|
|
161
160
|
return c.json({ error: "Task is paused" }, 400);
|
|
162
161
|
}
|
|
163
162
|
|
|
164
|
-
//
|
|
165
|
-
scheduler.triggerTask(taskId)
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
// Returns immediately — the run continues in the background.
|
|
164
|
+
const result = scheduler.triggerTask(taskId);
|
|
165
|
+
if (result === "already-running") {
|
|
166
|
+
return c.json({ error: "Task is already running", id: taskId }, 409);
|
|
167
|
+
}
|
|
168
|
+
if (result === "not-found") {
|
|
169
|
+
return c.json({ error: "Task not found" }, 404);
|
|
170
|
+
}
|
|
171
|
+
if (result === "unavailable") {
|
|
172
|
+
return c.json({ error: "Scheduler is not running" }, 503);
|
|
173
|
+
}
|
|
168
174
|
|
|
169
175
|
return c.json({ id: taskId, triggered: true });
|
|
170
176
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CronExpressionParser } from "cron-parser";
|
|
2
2
|
import { logger } from "../logger.js";
|
|
3
3
|
import type { Db } from "../storage/db.js";
|
|
4
|
+
import type { ScheduledTask } from "../types.js";
|
|
4
5
|
|
|
5
6
|
type TaskHandler = (task: {
|
|
6
7
|
id: number;
|
|
@@ -10,9 +11,26 @@ type TaskHandler = (task: {
|
|
|
10
11
|
silent: boolean;
|
|
11
12
|
}) => Promise<void>;
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Outcome of a manual trigger. `started` means the run was handed to the
|
|
16
|
+
* handler — it is *not* finished; agent runs take minutes. `unavailable`
|
|
17
|
+
* means the scheduler was never started, which is a server-state problem
|
|
18
|
+
* rather than anything about the task.
|
|
19
|
+
*/
|
|
20
|
+
export type TriggerResult =
|
|
21
|
+
| "started"
|
|
22
|
+
| "not-found"
|
|
23
|
+
| "already-running"
|
|
24
|
+
| "unavailable";
|
|
25
|
+
|
|
13
26
|
export class TaskScheduler {
|
|
14
27
|
private timer: NodeJS.Timeout | null = null;
|
|
15
28
|
private handler: TaskHandler | null = null;
|
|
29
|
+
/**
|
|
30
|
+
* Task ids with a run in flight. Both the poll and manual triggers mark
|
|
31
|
+
* here, so a task can never have two concurrent runs regardless of source.
|
|
32
|
+
*/
|
|
33
|
+
private readonly running = new Set<number>();
|
|
16
34
|
|
|
17
35
|
constructor(
|
|
18
36
|
private readonly db: Db,
|
|
@@ -28,40 +46,25 @@ export class TaskScheduler {
|
|
|
28
46
|
try {
|
|
29
47
|
const due = this.db.getDueTasks(Date.now());
|
|
30
48
|
for (const task of due) {
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
task.timezone ?? undefined,
|
|
37
|
-
);
|
|
38
|
-
this.db.updateTaskNextRun(task.id, next);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
await handler({
|
|
43
|
-
id: task.id,
|
|
44
|
-
spaceId: task.spaceId,
|
|
45
|
-
prompt: task.prompt,
|
|
46
|
-
createdBy: task.createdBy,
|
|
47
|
-
silent: task.silent === 1,
|
|
48
|
-
});
|
|
49
|
-
} catch (error) {
|
|
50
|
-
logger.error("Scheduler task handler failed", {
|
|
49
|
+
// A manual trigger may still be running this task. Skip rather than
|
|
50
|
+
// start a second run — the task stays due (its bookkeeping has not
|
|
51
|
+
// been applied), so the next poll picks it up once the run ends.
|
|
52
|
+
if (this.running.has(task.id)) {
|
|
53
|
+
logger.info("Skipping due task — a run is already in flight", {
|
|
51
54
|
taskId: task.id,
|
|
52
55
|
spaceId: task.spaceId,
|
|
53
|
-
error: error instanceof Error ? error.message : String(error),
|
|
54
56
|
});
|
|
57
|
+
continue;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
// `due` is a snapshot taken before the loop, and each iteration
|
|
61
|
+
// awaits a full agent run — minutes, during which a manual trigger
|
|
62
|
+
// can have run, rescheduled or deleted a later entry, and the user
|
|
63
|
+
// can have paused it. Re-read before committing to the run.
|
|
64
|
+
const fresh = this.db.getTask(task.id);
|
|
65
|
+
if (!fresh?.active || fresh.nextRunAt > Date.now()) continue;
|
|
66
|
+
|
|
67
|
+
await this.runTask(fresh, true);
|
|
65
68
|
}
|
|
66
69
|
} catch (error) {
|
|
67
70
|
logger.error(
|
|
@@ -122,18 +125,89 @@ export class TaskScheduler {
|
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Run one task through the handler, holding an in-flight mark for its whole
|
|
130
|
+
* duration.
|
|
131
|
+
*
|
|
132
|
+
* `applyBookkeeping` performs what the poll has always done around a run:
|
|
133
|
+
* advance `next_run_at` for cron tasks *before* executing, delete one-shot
|
|
134
|
+
* `at` tasks *after*. Both happen regardless of handler success. A manual
|
|
135
|
+
* trigger of a task that is not yet due passes `false` — an early preview
|
|
136
|
+
* run must not consume the schedule the user set up.
|
|
137
|
+
*/
|
|
138
|
+
private async runTask(
|
|
139
|
+
task: ScheduledTask,
|
|
140
|
+
applyBookkeeping: boolean,
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const handler = this.handler;
|
|
143
|
+
if (!handler) {
|
|
144
|
+
// Unreachable: start() assigns the handler before the first tick, and
|
|
145
|
+
// triggerTask returns "unavailable" without getting here.
|
|
146
|
+
logger.error("Refusing to run a task before the scheduler started", {
|
|
147
|
+
taskId: task.id,
|
|
148
|
+
spaceId: task.spaceId,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.running.add(task.id);
|
|
154
|
+
try {
|
|
155
|
+
if (applyBookkeeping && task.cron) {
|
|
156
|
+
const next = this.computeNextRun(task.cron, task.timezone ?? undefined);
|
|
157
|
+
this.db.updateTaskNextRun(task.id, next);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await handler({
|
|
162
|
+
id: task.id,
|
|
163
|
+
spaceId: task.spaceId,
|
|
164
|
+
prompt: task.prompt,
|
|
165
|
+
createdBy: task.createdBy,
|
|
166
|
+
silent: task.silent === 1,
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
logger.error("Scheduler task handler failed", {
|
|
170
|
+
taskId: task.id,
|
|
171
|
+
spaceId: task.spaceId,
|
|
172
|
+
error: error instanceof Error ? error.message : String(error),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (applyBookkeeping && task.at) {
|
|
177
|
+
this.db.deleteTaskById(task.id);
|
|
178
|
+
logger.info("One-shot task completed and deleted", {
|
|
179
|
+
taskId: task.id,
|
|
180
|
+
spaceId: task.spaceId,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
this.running.delete(task.id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Start a manual run of a task. Returns **immediately** with the verdict —
|
|
190
|
+
* the run itself continues in the background, so callers must never await
|
|
191
|
+
* completion (an agent run can take minutes, and a route that holds the
|
|
192
|
+
* connection open for it gives the UI no feedback at all).
|
|
193
|
+
*
|
|
194
|
+
* A task already running — whether triggered manually or by the poll — is
|
|
195
|
+
* rejected with `already-running` rather than queued behind the first run.
|
|
196
|
+
*/
|
|
197
|
+
triggerTask(taskId: number): TriggerResult {
|
|
198
|
+
if (!this.handler) return "unavailable";
|
|
127
199
|
const task = this.db.getTask(taskId);
|
|
128
|
-
if (!task?.active) return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
200
|
+
if (!task?.active) return "not-found";
|
|
201
|
+
if (this.running.has(task.id)) return "already-running";
|
|
202
|
+
|
|
203
|
+
// Already-due tasks get the poll's bookkeeping; running one manually must
|
|
204
|
+
// clear its due state, or the 5 s poll fires it again on its own.
|
|
205
|
+
const applyBookkeeping = task.nextRunAt <= Date.now();
|
|
206
|
+
|
|
207
|
+
// runTask's body runs synchronously up to its first await, so the
|
|
208
|
+
// in-flight mark is set before this returns — a second click in the same
|
|
209
|
+
// tick sees it and loses the race. Not awaited: fire-and-forget by design.
|
|
210
|
+
void this.runTask(task, applyBookkeeping);
|
|
211
|
+
return "started";
|
|
138
212
|
}
|
|
139
213
|
}
|
package/src/dashboard/index.html
CHANGED
|
@@ -496,6 +496,36 @@
|
|
|
496
496
|
|
|
497
497
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
498
498
|
|
|
499
|
+
/* ─── Toast ──────────────────────────────────────────────────────────── */
|
|
500
|
+
|
|
501
|
+
#toast {
|
|
502
|
+
position: fixed;
|
|
503
|
+
bottom: 16px;
|
|
504
|
+
left: 50%;
|
|
505
|
+
transform: translateX(-50%);
|
|
506
|
+
z-index: 50;
|
|
507
|
+
display: flex;
|
|
508
|
+
flex-direction: column;
|
|
509
|
+
gap: 8px;
|
|
510
|
+
pointer-events: none;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.toast {
|
|
514
|
+
background: var(--surface-elevated);
|
|
515
|
+
border: 1px solid var(--border);
|
|
516
|
+
border-left: 3px solid var(--accent);
|
|
517
|
+
border-radius: var(--radius);
|
|
518
|
+
padding: 10px 14px;
|
|
519
|
+
max-width: 90vw;
|
|
520
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.toast.error { border-left-color: var(--color-error); }
|
|
524
|
+
.toast.success { border-left-color: var(--color-success); }
|
|
525
|
+
|
|
526
|
+
/* htmx sets [disabled] via hx-disabled-elt while a request is in flight */
|
|
527
|
+
.btn[disabled] { opacity: 0.5; cursor: default; }
|
|
528
|
+
|
|
499
529
|
@media (prefers-reduced-motion: reduce) {
|
|
500
530
|
.badge.pulse,
|
|
501
531
|
.status.active::before { animation: none; }
|
|
@@ -601,6 +631,8 @@
|
|
|
601
631
|
</main>
|
|
602
632
|
</div>
|
|
603
633
|
|
|
634
|
+
<div id="toast" aria-live="polite"></div>
|
|
635
|
+
|
|
604
636
|
<script>
|
|
605
637
|
// Navigation (global for hx-on/htmx; Biome cannot see HTML callers)
|
|
606
638
|
globalThis.setActiveNav = function setActiveNav(el) {
|
|
@@ -692,6 +724,38 @@
|
|
|
692
724
|
}
|
|
693
725
|
});
|
|
694
726
|
|
|
727
|
+
// Transient feedback. Actions use hx-swap="none", so without this the page
|
|
728
|
+
// just silently refreshes and a rejected action looks identical to an
|
|
729
|
+
// accepted one.
|
|
730
|
+
function showToast(message, kind) {
|
|
731
|
+
const host = document.getElementById('toast');
|
|
732
|
+
if (!host) return;
|
|
733
|
+
const el = document.createElement('div');
|
|
734
|
+
el.className = 'toast ' + (kind || '');
|
|
735
|
+
el.textContent = message;
|
|
736
|
+
host.appendChild(el);
|
|
737
|
+
setTimeout(() => el.remove(), 5000);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// "Run now" is fire-and-forget: the agent run takes minutes, so the only
|
|
741
|
+
// signal the user gets is this toast. 409 means a run is already in flight.
|
|
742
|
+
document.body.addEventListener('htmx:afterRequest', (e) => {
|
|
743
|
+
const path = e.detail.requestConfig?.path || '';
|
|
744
|
+
if (!/\/api\/tasks\/\d+\/run$/.test(path)) return;
|
|
745
|
+
const xhr = e.detail.xhr;
|
|
746
|
+
if (xhr.status === 409) {
|
|
747
|
+
showToast('That task is already running — the first run is still in progress.', 'error');
|
|
748
|
+
} else if (xhr.status >= 400) {
|
|
749
|
+
let message = 'Could not start the task.';
|
|
750
|
+
try {
|
|
751
|
+
message = JSON.parse(xhr.response)?.error || message;
|
|
752
|
+
} catch { /* non-JSON error body — keep the generic message */ }
|
|
753
|
+
showToast(message, 'error');
|
|
754
|
+
} else {
|
|
755
|
+
showToast('Task started — the reply arrives when the run finishes.', 'success');
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
|
|
695
759
|
// Refresh current page after htmx actions (like delete, pause, etc.)
|
|
696
760
|
document.body.addEventListener('htmx:afterRequest', (e) => {
|
|
697
761
|
if (e.detail.requestConfig.verb !== 'get') {
|
package/src/storage/db.ts
CHANGED
|
@@ -1349,9 +1349,23 @@ export class Db {
|
|
|
1349
1349
|
return result.changes > 0;
|
|
1350
1350
|
}
|
|
1351
1351
|
|
|
1352
|
-
|
|
1352
|
+
/**
|
|
1353
|
+
* Seed admin roles. Returns the ids whose existing row held a non-admin
|
|
1354
|
+
* role and was overridden back to admin, so the caller can log the
|
|
1355
|
+
* re-promotion (this layer stays log-free).
|
|
1356
|
+
*/
|
|
1357
|
+
seedAdmins(spaceId: string, adminIds: string[]): string[] {
|
|
1353
1358
|
const now = Date.now();
|
|
1359
|
+
const repromoted: string[] = [];
|
|
1354
1360
|
for (const id of adminIds) {
|
|
1361
|
+
const existing = this.db
|
|
1362
|
+
.query(
|
|
1363
|
+
"SELECT role FROM space_roles WHERE space_id = ? AND platform_user_id = ?",
|
|
1364
|
+
)
|
|
1365
|
+
.get(spaceId, id) as { role: string } | null;
|
|
1366
|
+
if (existing && existing.role !== "admin") {
|
|
1367
|
+
repromoted.push(id);
|
|
1368
|
+
}
|
|
1355
1369
|
this.db
|
|
1356
1370
|
.query(
|
|
1357
1371
|
`INSERT INTO space_roles(space_id, platform_user_id, role, granted_by, created_at, updated_at)
|
|
@@ -1362,6 +1376,7 @@ export class Db {
|
|
|
1362
1376
|
)
|
|
1363
1377
|
.run(spaceId, id, now, now);
|
|
1364
1378
|
}
|
|
1379
|
+
return repromoted;
|
|
1365
1380
|
}
|
|
1366
1381
|
|
|
1367
1382
|
// --- Space Config ---
|