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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -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 = db.getRole(spaceId, platformUserId) ?? "member";
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
  }
@@ -147,7 +147,14 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
147
147
  }
148
148
 
149
149
  if (authenticated) {
150
- core.db.seedAdmins(spaceId, [callerId]);
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", async (c) => {
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
- const triggered = await core.scheduler.triggerTask(taskId);
2484
- if (!triggered) {
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) => {
@@ -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
- // Trigger async - don't wait for completion
165
- scheduler.triggerTask(taskId).catch((err) => {
166
- logger.error("Task trigger failed", { taskId, error: String(err) });
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
- // For cron tasks, update next run before execution
32
- // For at-tasks, we'll delete after execution
33
- if (task.cron) {
34
- const next = this.computeNextRun(
35
- task.cron,
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
- // For at-tasks, delete after execution (regardless of success/failure)
58
- if (task.at) {
59
- this.db.deleteTaskById(task.id);
60
- logger.info("One-shot task completed and deleted", {
61
- taskId: task.id,
62
- spaceId: task.spaceId,
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
- async triggerTask(taskId: number): Promise<boolean> {
126
- if (!this.handler) return false;
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 false;
129
-
130
- await this.handler({
131
- id: task.id,
132
- spaceId: task.spaceId,
133
- prompt: task.prompt,
134
- createdBy: task.createdBy,
135
- silent: task.silent === 1,
136
- });
137
- return true;
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
  }
@@ -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
- seedAdmins(spaceId: string, adminIds: string[]): void {
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 ---