@sleep2agi/commhub-server 0.9.0-preview.140 → 0.9.0-preview.141

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": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.140",
3
+ "version": "0.9.0-preview.141",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db.ts CHANGED
@@ -1734,6 +1734,14 @@ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_state TEXT"
1734
1734
  // Board #733 — schedules an Agent created over MCP name the creating node. NULL = created by a person
1735
1735
  // (REST / Dashboard) or a row from before this column; old rows keep NULL and old code never reads it.
1736
1736
  try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN created_by_node_id TEXT"); } catch {}
1737
+
1738
+ try { db.exec(`CREATE TABLE IF NOT EXISTS scheduled_task_watchers (
1739
+ schedule_id TEXT NOT NULL,
1740
+ user_id TEXT NOT NULL,
1741
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1742
+ PRIMARY KEY (schedule_id, user_id)
1743
+ )`); } catch {}
1744
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_scheduled_task_watchers_user ON scheduled_task_watchers(user_id)"); } catch {}
1737
1745
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_creator_node ON scheduled_tasks(network_id, created_by_node_id)"); } catch {}
1738
1746
  // #523 — consecutive-failure alert bookkeeping (scheduled-failures.ts). Additive and nullable:
1739
1747
  // an older Hub never reads them, so rolling back is safe. failure_alert_at = ISO time of the last
@@ -76,7 +76,7 @@ const GOLDEN_RESPONSE_KEYS = {
76
76
  "schedule_id", "network_id", "name", "target_node_id", "target_alias",
77
77
  "task_content", "priority", "schedule_type", "timezone", "overlap_policy",
78
78
  "misfire_policy", "status", "next_run_at", "last_run_at", "revision",
79
- "created_at", "updated_at", "schedule",
79
+ "created_at", "updated_at", "schedule", "watchers", "is_watcher", "can_edit", "can_manage_watchers",
80
80
  ],
81
81
  } as const;
82
82
 
@@ -635,4 +635,45 @@ describe("Hub scheduled task API and dispatcher", () => {
635
635
  const runs = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/runs?network_id=${encodeURIComponent(networkId)}`);
636
636
  expect(runs.body.runs.length).toBeGreaterThanOrEqual(4);
637
637
  });
638
+
639
+ test("a watcher can edit the schedule but cannot cancel it or change watchers", async () => {
640
+ const viewerId = db.get<{ user_id: string }>("SELECT user_id FROM network_members WHERE network_id = ?1 AND role = 'viewer'", networkId)!.user_id;
641
+ const schedule = await createSchedule(`watchers-${Date.now()}`);
642
+ const bound = await api(ownerToken, `/api/scheduled-tasks/${schedule.schedule_id}?network_id=${encodeURIComponent(networkId)}`, {
643
+ method: "PATCH",
644
+ body: JSON.stringify({ revision: schedule.revision, watchers: [viewerId] }),
645
+ });
646
+ expect(bound.status).toBe(200);
647
+ expect(bound.body.schedule.watchers.map((w: { user_id: string }) => w.user_id)).toEqual([viewerId]);
648
+ expect(bound.body.schedule.can_manage_watchers).toBe(true);
649
+
650
+ const listed = await api(viewerToken, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
651
+ const row = listed.body.schedules.find((item: { schedule_id: string }) => item.schedule_id === schedule.schedule_id);
652
+ expect(row?.is_watcher).toBe(true);
653
+ expect(row?.can_edit).toBe(true);
654
+ expect(row?.can_manage_watchers).toBe(false);
655
+
656
+ const edited = await api(viewerToken, `/api/scheduled-tasks/${schedule.schedule_id}?network_id=${encodeURIComponent(networkId)}`, {
657
+ method: "PATCH",
658
+ body: JSON.stringify({ revision: row.revision, name: "edited by watcher" }),
659
+ });
660
+ expect(edited.status).toBe(200);
661
+ expect(edited.body.schedule.name).toBe("edited by watcher");
662
+
663
+ const manage = await api(viewerToken, `/api/scheduled-tasks/${schedule.schedule_id}?network_id=${encodeURIComponent(networkId)}`, {
664
+ method: "PATCH",
665
+ body: JSON.stringify({ revision: edited.body.schedule.revision, watchers: [] }),
666
+ });
667
+ expect(manage.status).toBe(403);
668
+ expect(manage.body.error).toBe("watcher_cannot_manage_watchers");
669
+
670
+ const cancel = await api(viewerToken, `/api/scheduled-tasks/${schedule.schedule_id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
671
+ expect(cancel.status).toBe(403);
672
+ expect(cancel.body.error).toBe("watcher_cannot_cancel");
673
+
674
+ const runNow = await api(viewerToken, `/api/scheduled-tasks/${schedule.schedule_id}/run-now?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
675
+ expect(runNow.status).toBe(403);
676
+ expect(runNow.body.error).toBe("watcher_cannot_run");
677
+ });
678
+
638
679
  });
@@ -735,6 +735,63 @@ function writeAllowed(ctx: ScheduledRequestContext, networkId: string | null): b
735
735
  return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
736
736
  }
737
737
 
738
+
739
+ type WatcherPublic = { user_id: string; username: string; display_name: string };
740
+
741
+ function scheduleWatchers(scheduleId: string): WatcherPublic[] {
742
+ return db.all<{ user_id: string; username: string; display_name: string | null }>(
743
+ `SELECT w.user_id, u.username, u.display_name
744
+ FROM scheduled_task_watchers w JOIN users u ON u.user_id = w.user_id
745
+ WHERE w.schedule_id = ?1 ORDER BY u.username`,
746
+ [scheduleId],
747
+ ).map(row => ({ user_id: row.user_id, username: row.username, display_name: row.display_name ?? "" }));
748
+ }
749
+
750
+ function userInNetwork(userId: string, networkId: string): boolean {
751
+ if (db.get("SELECT 1 AS ok FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId])) return true;
752
+ return !!db.get("SELECT 1 AS ok FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
753
+ }
754
+
755
+ function replaceScheduleWatchers(scheduleId: string, networkId: string, raw: unknown): void {
756
+ if (!Array.isArray(raw) || raw.length > 20) throw new ScheduleInputError("invalid_watchers", 400);
757
+ const ids: string[] = [];
758
+ for (const item of raw) {
759
+ const id = typeof item === "string" ? item : item && typeof item === "object" && typeof (item as { user_id?: unknown }).user_id === "string" ? (item as { user_id: string }).user_id : "";
760
+ if (!id || id.length > 200) throw new ScheduleInputError("invalid_watchers", 400);
761
+ if (!ids.includes(id)) ids.push(id);
762
+ }
763
+ for (const id of ids) {
764
+ if (!userInNetwork(id, networkId)) throw new ScheduleInputError("watcher_not_in_network", 400, { user_id: id });
765
+ }
766
+ db.run("DELETE FROM scheduled_task_watchers WHERE schedule_id = ?1", [scheduleId]);
767
+ for (const id of ids) db.run("INSERT INTO scheduled_task_watchers (schedule_id, user_id) VALUES (?1, ?2)", [scheduleId, id]);
768
+ }
769
+
770
+ function watchingSchedule(scheduleId: string, userId: string | null): boolean {
771
+ if (!userId) return false;
772
+ return !!db.get("SELECT 1 AS ok FROM scheduled_task_watchers WHERE schedule_id = ?1 AND user_id = ?2", [scheduleId, userId]);
773
+ }
774
+
775
+ function canManageWatchers(row: ScheduledRow, ctx: ScheduledRequestContext): boolean {
776
+ const uid = ctx.auth?.userId ?? null;
777
+ if (ctx.isAdmin) return true;
778
+ if (uid && row.created_by === uid) return true;
779
+ return !row.created_by && writeAllowed(ctx, row.network_id);
780
+ }
781
+
782
+ function decorateSchedule(row: ScheduledRow, ctx: ScheduledRequestContext): Record<string, unknown> {
783
+ const watchers = scheduleWatchers(row.schedule_id);
784
+ const uid = ctx.auth?.userId ?? null;
785
+ const isWatcher = !!uid && watchers.some(w => w.user_id === uid);
786
+ return {
787
+ ...decodeRow(row),
788
+ watchers,
789
+ is_watcher: isWatcher,
790
+ can_edit: writeAllowed(ctx, row.network_id) || isWatcher,
791
+ can_manage_watchers: canManageWatchers(row, ctx),
792
+ };
793
+ }
794
+
738
795
  export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): Promise<Response | null> {
739
796
  const { req, url } = ctx;
740
797
  if (url.pathname !== "/api/scheduled-tasks" && !url.pathname.startsWith("/api/scheduled-tasks/")) return null;
@@ -747,7 +804,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
747
804
  const status = url.searchParams.get("status");
748
805
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
749
806
  sql += " ORDER BY updated_at DESC LIMIT 500";
750
- return Response.json({ ok: true, schedules: db.all<ScheduledRow>(sql, ...params).map(decodeRow) });
807
+ return Response.json({ ok: true, schedules: db.all<ScheduledRow>(sql, ...params).map(row => decorateSchedule(row, ctx)) });
751
808
  }
752
809
 
753
810
  if (url.pathname === "/api/scheduled-tasks" && req.method === "POST") {
@@ -758,8 +815,10 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
758
815
  if (!writeAllowed(ctx, networkId)) return jsonError("permission_denied", 403);
759
816
  try {
760
817
  const created = insertSchedule(networkId, ctx.auth?.userId ?? null, null, validateScheduleCreate(networkId, body));
761
- return Response.json({ ok: true, schedule: decodeRow(created) }, { status: 201 });
818
+ if (body.watchers !== undefined) replaceScheduleWatchers(created.schedule_id, networkId, body.watchers);
819
+ return Response.json({ ok: true, schedule: decorateSchedule(getScheduleRow(created.schedule_id)!, ctx) }, { status: 201 });
762
820
  } catch (e: any) {
821
+ if (e instanceof ScheduleInputError) return jsonError(e.code, e.status, e.extra);
763
822
  const code = String(e?.message || "invalid_schedule");
764
823
  const status = code === "target_node_not_found" ? 404 : 400;
765
824
  return jsonError(code, status);
@@ -777,8 +836,12 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
777
836
  const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
778
837
  return Response.json({ ok: true, ...scheduleRuns(row, limit) });
779
838
  }
780
- if (!sub && req.method === "GET") return Response.json({ ok: true, schedule: decodeRow(row) });
781
- if (!writeAllowed(ctx, row.network_id)) return jsonError("permission_denied", 403);
839
+ if (!sub && req.method === "GET") return Response.json({ ok: true, schedule: decorateSchedule(row, ctx) });
840
+ const watcher = watchingSchedule(row.schedule_id, ctx.auth?.userId ?? null);
841
+ const writer = writeAllowed(ctx, row.network_id);
842
+ if (!writer && !watcher) return jsonError("permission_denied", 403);
843
+ if (!writer && (sub === "cancel" || (!sub && req.method === "DELETE"))) return jsonError("watcher_cannot_cancel", 403);
844
+ if (!writer && sub === "run-now") return jsonError("watcher_cannot_run", 403);
782
845
 
783
846
  if (sub === "run-now" && req.method === "POST") {
784
847
  if (row.status === "cancelled") return jsonError("schedule_cancelled", 409);
@@ -808,8 +871,11 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
808
871
  if (!sub && req.method === "PATCH") {
809
872
  let body: Record<string, unknown>;
810
873
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
874
+ if (body.watchers !== undefined && !canManageWatchers(row, ctx)) return jsonError("watcher_cannot_manage_watchers", 403);
811
875
  try {
812
- return Response.json({ ok: true, schedule: decodeRow(patchSchedule(row, body, ctx.auth?.userId ?? null)) });
876
+ const updated = patchSchedule(row, body, ctx.auth?.userId ?? null);
877
+ if (body.watchers !== undefined) replaceScheduleWatchers(updated.schedule_id, updated.network_id, body.watchers);
878
+ return Response.json({ ok: true, schedule: decorateSchedule(getScheduleRow(updated.schedule_id)!, ctx) });
813
879
  } catch (e: any) {
814
880
  if (e instanceof ScheduleInputError) return jsonError(e.code, e.status, e.extra);
815
881
  throw e;