@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.
|
|
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
|
});
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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:
|
|
781
|
-
|
|
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
|
-
|
|
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;
|