@sleep2agi/commhub-server 0.9.0-preview.113 → 0.9.0-preview.115
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/auth.ts +1 -1
- package/src/create-node-unicode-name.test.ts +2 -0
- package/src/db.ts +29 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-permissions.ts +4 -1
- package/src/node-token-privilege-http.test.ts +9 -0
- package/src/rename.ts +15 -0
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/rest-projections.ts +1 -1
- package/src/schedule-agent-mcp-http.test.ts +315 -0
- package/src/schedule-agent.ts +173 -0
- package/src/scheduled-tasks.ts +174 -89
- package/src/server.ts +18 -3
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/tool-audience-http.test.ts +8 -1
- package/src/tool-audience.ts +3 -0
- package/src/tools.ts +161 -8
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Board #733 — Agents manage Hub scheduled tasks over MCP (schedule_* tools in tools.ts).
|
|
2
|
+
//
|
|
3
|
+
// REST /api/scheduled-tasks stays people-only (node tokens → 403 user_token_required). This module is the
|
|
4
|
+
// agent surface; every validation / write goes through the same helpers as REST (scheduled-tasks.ts).
|
|
5
|
+
//
|
|
6
|
+
// Permission model (node token = one bound node in one network):
|
|
7
|
+
// • Scope: only schedules in the token's network that TARGET this node or that this node CREATED
|
|
8
|
+
// (created_by_node_id). Anything else reads as schedule_not_found (no existence probe).
|
|
9
|
+
// • Write gate: the owner must be able to write in the network (same canWrite as send_task), and a
|
|
10
|
+
// read-only node (permission_mode=readonly) cannot mutate schedules at all.
|
|
11
|
+
// • Target gate: a schedule (create, retarget, update, run-now) aimed at another node needs exactly the
|
|
12
|
+
// verdict send_task gets for that target (node-permissions.ts dispatchVerdict, owner's Agent grants +
|
|
13
|
+
// the node's mode). Stricter than send_task in one way: the verdict is enforced even under
|
|
14
|
+
// COMMHUB_NODE_PERMISSIONS=log, because the scheduler would refuse the run later anyway.
|
|
15
|
+
// • Mutations (update / pause / resume / cancel / run_now) only on schedules THIS node created
|
|
16
|
+
// (created_by_node_id = self). A person's schedule that targets the node is read-only for it.
|
|
17
|
+
// • created_by = the node's owner user id (the identity send_task's dispatchVerdict checks), so the
|
|
18
|
+
// scheduler's per-run canMessageAgent(created_by, …) keeps re-checking the owner's grants on every run.
|
|
19
|
+
// A node without a known owner cannot create schedules (a NULL created_by would skip that re-check).
|
|
20
|
+
// • Quota: ≤ COMMHUB_AGENT_SCHEDULE_QUOTA (default 20) active/paused schedules created per node.
|
|
21
|
+
// Minimum cadence is the shared parseScheduleSpec rule (interval ≥ 60 s), the same as REST.
|
|
22
|
+
|
|
23
|
+
import { db } from "./db.js";
|
|
24
|
+
import {
|
|
25
|
+
cancelSchedule, decodeRowForAgent, dispatchScheduledOccurrence, getScheduleRow, insertSchedule, patchSchedule,
|
|
26
|
+
scheduleRuns, ScheduleInputError, validateScheduleCreate, type ScheduledRow,
|
|
27
|
+
} from "./scheduled-tasks.js";
|
|
28
|
+
import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
29
|
+
import { dispatchVerdict, nodePermissionDeniedBody, recordNodePermission, type NodeIdentity } from "./node-permissions.js";
|
|
30
|
+
|
|
31
|
+
export type AgentScheduleResult = Record<string, unknown> & { ok: boolean };
|
|
32
|
+
|
|
33
|
+
export function agentScheduleQuota(): number {
|
|
34
|
+
const n = Number(process.env.COMMHUB_AGENT_SCHEDULE_QUOTA);
|
|
35
|
+
return Number.isSafeInteger(n) && n > 0 ? n : 20;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const fail = (error: string, extra: Record<string, unknown> = {}): AgentScheduleResult => ({ ok: false, error, ...extra });
|
|
39
|
+
|
|
40
|
+
/** The scope rule. Exported so the mutation tests can pin it. */
|
|
41
|
+
export function nodeMaySeeSchedule(id: NodeIdentity, row: ScheduledRow): boolean {
|
|
42
|
+
if (!id.nodeId || row.network_id !== id.networkId) return false;
|
|
43
|
+
return row.target_node_id === id.nodeId || row.created_by_node_id === id.nodeId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function visibleRow(id: NodeIdentity, scheduleId: string): ScheduledRow | null {
|
|
47
|
+
const row = getScheduleRow(scheduleId);
|
|
48
|
+
return row && nodeMaySeeSchedule(id, row) ? row : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Visible AND created by this node; else the error to return. Exported for the mutation tests. */
|
|
52
|
+
export function nodeMayMutateSchedule(id: NodeIdentity, row: ScheduledRow): boolean {
|
|
53
|
+
return !!id.nodeId && row.created_by_node_id === id.nodeId;
|
|
54
|
+
}
|
|
55
|
+
function ownRow(id: NodeIdentity, scheduleId: string): ScheduledRow | AgentScheduleResult {
|
|
56
|
+
const row = visibleRow(id, scheduleId);
|
|
57
|
+
if (!row) return fail("schedule_not_found");
|
|
58
|
+
if (!nodeMayMutateSchedule(id, row)) return fail("not_schedule_creator", { message: "only schedules this node created can be changed" });
|
|
59
|
+
return row;
|
|
60
|
+
}
|
|
61
|
+
const isRow = (r: ScheduledRow | AgentScheduleResult): r is ScheduledRow => typeof (r as ScheduledRow).schedule_id === "string";
|
|
62
|
+
|
|
63
|
+
/** send_task's verdict for this target, always enforced (see header). null = allowed. */
|
|
64
|
+
function targetDenied(id: NodeIdentity, route: string, target: { node_id: string; alias: string }): AgentScheduleResult | null {
|
|
65
|
+
const verdict = dispatchVerdict(id, { alias: target.alias, nodeId: target.node_id });
|
|
66
|
+
if (!verdict) return null;
|
|
67
|
+
recordNodePermission(id, route, verdict.reason, verdict.sample);
|
|
68
|
+
return { ...nodePermissionDeniedBody(verdict.reason, route) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeGate(id: NodeIdentity, canWrite: boolean, route: string): AgentScheduleResult | null {
|
|
72
|
+
if (!id.nodeId) return fail("node_identity_unbound");
|
|
73
|
+
if (!canWrite) return fail("permission_denied", { message: "this node's owner cannot write in this network" });
|
|
74
|
+
if (id.mode === "readonly") return { ...nodePermissionDeniedBody("mode_readonly", route) };
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inputError(e: any): AgentScheduleResult {
|
|
79
|
+
if (e instanceof ScheduleInputError) return fail(e.code, e.extra);
|
|
80
|
+
return fail(String(e?.message || "invalid_schedule"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function agentListSchedules(id: NodeIdentity, status?: string): AgentScheduleResult {
|
|
84
|
+
if (!id.nodeId) return fail("node_identity_unbound");
|
|
85
|
+
const params: unknown[] = [id.networkId, id.nodeId];
|
|
86
|
+
let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE network_id = ?1 AND (target_node_id = ?2 OR created_by_node_id = ?2)`;
|
|
87
|
+
if (status) { sql += " AND status = ?3"; params.push(status); }
|
|
88
|
+
sql += " ORDER BY updated_at DESC LIMIT 200";
|
|
89
|
+
return { ok: true, schedules: db.all<ScheduledRow>(sql, ...params).map(decodeRowForAgent) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function agentGetSchedule(id: NodeIdentity, scheduleId: string): AgentScheduleResult {
|
|
93
|
+
const row = visibleRow(id, scheduleId);
|
|
94
|
+
return row ? { ok: true, schedule: decodeRowForAgent(row) } : fail("schedule_not_found");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function agentScheduleRuns(id: NodeIdentity, scheduleId: string, limit?: number): AgentScheduleResult {
|
|
98
|
+
const row = visibleRow(id, scheduleId);
|
|
99
|
+
if (!row) return fail("schedule_not_found");
|
|
100
|
+
return { ok: true, ...scheduleRuns(row, Math.max(1, Math.min(200, Math.floor(Number(limit)) || 50))) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function agentCreateSchedule(id: NodeIdentity, canWrite: boolean, body: Record<string, unknown>): AgentScheduleResult {
|
|
104
|
+
const route = "mcp:schedule_create";
|
|
105
|
+
const gate = writeGate(id, canWrite, route);
|
|
106
|
+
if (gate) return gate;
|
|
107
|
+
if (!id.ownerUserId) return { ...nodePermissionDeniedBody("owner_unknown", route) };
|
|
108
|
+
const input = { ...body, target_node_id: body.target_node_id ?? id.nodeId };
|
|
109
|
+
let v: ReturnType<typeof validateScheduleCreate>;
|
|
110
|
+
try { v = validateScheduleCreate(id.networkId, input); } catch (e) { return inputError(e); }
|
|
111
|
+
const denied = targetDenied(id, route, v.target);
|
|
112
|
+
if (denied) return denied;
|
|
113
|
+
const quota = agentScheduleQuota();
|
|
114
|
+
const used = Number(db.get<{ n: number | string }>(
|
|
115
|
+
"SELECT COUNT(*) AS n FROM scheduled_tasks WHERE network_id = ?1 AND created_by_node_id = ?2 AND status IN ('active', 'paused')",
|
|
116
|
+
id.networkId, id.nodeId,
|
|
117
|
+
)?.n ?? 0);
|
|
118
|
+
if (used >= quota) return fail("schedule_quota_exceeded", { quota, active: used, message: "cancel one of this node's schedules first" });
|
|
119
|
+
const row = insertSchedule(id.networkId, id.ownerUserId, id.nodeId, v);
|
|
120
|
+
return { ok: true, schedule: decodeRowForAgent(row) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function agentUpdateSchedule(id: NodeIdentity, canWrite: boolean, scheduleId: string, body: Record<string, unknown>): AgentScheduleResult {
|
|
124
|
+
const route = "mcp:schedule_update";
|
|
125
|
+
const gate = writeGate(id, canWrite, route);
|
|
126
|
+
if (gate) return gate;
|
|
127
|
+
const row = ownRow(id, scheduleId);
|
|
128
|
+
if (!isRow(row)) return row;
|
|
129
|
+
if (body.base_revision !== undefined && Number(body.base_revision) !== row.revision) {
|
|
130
|
+
return fail("revision_conflict", { current_revision: row.revision });
|
|
131
|
+
}
|
|
132
|
+
const retarget = body.target_node_id !== undefined && body.target_node_id !== row.target_node_id;
|
|
133
|
+
let target = { node_id: row.target_node_id, alias: row.target_alias };
|
|
134
|
+
if (retarget) {
|
|
135
|
+
const node = db.get<{ node_id: string; alias: string | null }>(
|
|
136
|
+
"SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", String(body.target_node_id), row.network_id);
|
|
137
|
+
if (!node?.alias) return fail("target_node_not_found");
|
|
138
|
+
target = { node_id: node.node_id, alias: node.alias };
|
|
139
|
+
}
|
|
140
|
+
const denied = targetDenied(id, route, target);
|
|
141
|
+
if (denied) return denied;
|
|
142
|
+
try {
|
|
143
|
+
const patched = patchSchedule(row, { ...body, revision: body.revision ?? row.revision });
|
|
144
|
+
return { ok: true, schedule: decodeRowForAgent(patched) };
|
|
145
|
+
} catch (e) { return inputError(e); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function agentCancelSchedule(id: NodeIdentity, canWrite: boolean, scheduleId: string): AgentScheduleResult {
|
|
149
|
+
const gate = writeGate(id, canWrite, "mcp:schedule_cancel");
|
|
150
|
+
if (gate) return gate;
|
|
151
|
+
const row = ownRow(id, scheduleId);
|
|
152
|
+
if (!isRow(row)) return row;
|
|
153
|
+
cancelSchedule(row);
|
|
154
|
+
return { ok: true, status: "cancelled" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function agentRunScheduleNow(id: NodeIdentity, canWrite: boolean, scheduleId: string): AgentScheduleResult {
|
|
158
|
+
const route = "mcp:schedule_run_now";
|
|
159
|
+
const gate = writeGate(id, canWrite, route);
|
|
160
|
+
if (gate) return gate;
|
|
161
|
+
const row = ownRow(id, scheduleId);
|
|
162
|
+
if (!isRow(row)) return row;
|
|
163
|
+
if (row.status === "cancelled") return fail("schedule_cancelled");
|
|
164
|
+
const denied = targetDenied(id, route, { node_id: row.target_node_id, alias: row.target_alias });
|
|
165
|
+
if (denied) return denied;
|
|
166
|
+
try {
|
|
167
|
+
const result = dispatchScheduledOccurrence(row, new Date().toISOString(), false);
|
|
168
|
+
const { event: _event, ...rest } = result;
|
|
169
|
+
return { ok: result.status !== "failed", ...rest };
|
|
170
|
+
} catch (e: any) {
|
|
171
|
+
return fail("dispatch_failed", { message: String(e?.message || e).slice(0, 500) });
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limi
|
|
|
4
4
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
5
5
|
import { assertNodeHealthy } from "./node-health-guard.js";
|
|
6
6
|
import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
7
|
-
import { canMessageAgent } from "./agent-access.js";
|
|
7
|
+
import { canMessageAgent, type AgentRef } from "./agent-access.js";
|
|
8
|
+
import { dispatchVerdict } from "./node-permissions.js";
|
|
8
9
|
import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
9
10
|
import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
10
11
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
@@ -19,10 +20,11 @@ export type ScheduleSpec =
|
|
|
19
20
|
|
|
20
21
|
export type MisfirePolicy = "catch_up_once" | "skip";
|
|
21
22
|
|
|
22
|
-
type ScheduledRow = {
|
|
23
|
+
export type ScheduledRow = {
|
|
23
24
|
schedule_id: string;
|
|
24
25
|
network_id: string;
|
|
25
26
|
created_by: string | null;
|
|
27
|
+
created_by_node_id: string | null;
|
|
26
28
|
name: string;
|
|
27
29
|
target_node_id: string;
|
|
28
30
|
target_alias: string;
|
|
@@ -178,13 +180,135 @@ export function nextOccurrence(spec: ScheduleSpec, timezone: string, after: Date
|
|
|
178
180
|
throw new Error("next_occurrence_unresolvable");
|
|
179
181
|
}
|
|
180
182
|
|
|
183
|
+
/** REST shape (pinned by rest-explicit-columns-http.test.ts): creator ids stay server-side. */
|
|
181
184
|
function decodeRow(row: ScheduledRow): Record<string, unknown> {
|
|
182
185
|
let schedule: unknown = null;
|
|
183
186
|
try { schedule = JSON.parse(row.schedule_json); } catch {}
|
|
184
|
-
const { created_by: _createdBy, schedule_json: _scheduleJson, ...publicRow } = row;
|
|
187
|
+
const { created_by: _createdBy, created_by_node_id: _createdByNode, schedule_json: _scheduleJson, ...publicRow } = row;
|
|
185
188
|
return { ...publicRow, schedule };
|
|
186
189
|
}
|
|
187
190
|
|
|
191
|
+
/** MCP (agent) shape: REST shape + which node created it (null = a person / old row). Never the user id. */
|
|
192
|
+
export function decodeRowForAgent(row: ScheduledRow): Record<string, unknown> {
|
|
193
|
+
return { ...decodeRow(row), created_by_node_id: row.created_by_node_id ?? null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function getScheduleRow(scheduleId: string): ScheduledRow | null {
|
|
197
|
+
return db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, scheduleId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export class ScheduleInputError extends Error {
|
|
201
|
+
constructor(public code: string, public status: number, public extra: Record<string, unknown> = {}) { super(code); }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type ValidatedScheduleCreate = {
|
|
205
|
+
name: string; content: string; priority: string; misfirePolicy: MisfirePolicy;
|
|
206
|
+
target: { node_id: string; alias: string }; spec: ScheduleSpec; timezone: string; next: Date;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Shared by REST POST and MCP schedule_create. Throws Error(code) like before. */
|
|
210
|
+
export function validateScheduleCreate(networkId: string, body: Record<string, unknown>): ValidatedScheduleCreate {
|
|
211
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
212
|
+
const content = typeof body.task === "string" ? body.task.trim() : "";
|
|
213
|
+
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
214
|
+
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
215
|
+
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
216
|
+
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
217
|
+
const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
|
|
218
|
+
const target = validateTarget(networkId, body.target_node_id);
|
|
219
|
+
const { spec, timezone } = parseScheduleSpec(body.schedule, body.timezone);
|
|
220
|
+
const next = nextOccurrence(spec, timezone, new Date());
|
|
221
|
+
if (!next) throw new Error("schedule_has_no_future_occurrence");
|
|
222
|
+
return { name, content, priority, misfirePolicy, target, spec, timezone, next };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function insertSchedule(networkId: string, createdBy: string | null, createdByNodeId: string | null, v: ValidatedScheduleCreate): ScheduledRow {
|
|
226
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
227
|
+
db.run(
|
|
228
|
+
`INSERT INTO scheduled_tasks
|
|
229
|
+
(schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias, task_content, priority, schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, next_run_at)
|
|
230
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'skip', ?13, ?14)`,
|
|
231
|
+
[scheduleId, networkId, createdBy, createdByNodeId, v.name, v.target.node_id, v.target.alias, v.content, v.priority, v.spec.type, JSON.stringify(v.spec), v.timezone, v.misfirePolicy, iso(v.next)],
|
|
232
|
+
);
|
|
233
|
+
return getScheduleRow(scheduleId)!;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Idempotent cancel (shared by REST DELETE / POST cancel and MCP schedule_cancel). */
|
|
237
|
+
export function cancelSchedule(row: ScheduledRow): void {
|
|
238
|
+
if (row.status === "cancelled") return;
|
|
239
|
+
db.run("UPDATE scheduled_tasks SET status = 'cancelled', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1", [row.schedule_id]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function scheduleRuns(row: ScheduledRow, limit: number): Record<string, unknown> {
|
|
243
|
+
const runs = db.all(
|
|
244
|
+
// #523 — a task that ended `failed` leaves run.error_message empty; its reason is the task's reply text.
|
|
245
|
+
`SELECT r.run_id, r.schedule_id, r.scheduled_for, r.task_id, r.status, r.error_code,
|
|
246
|
+
CASE WHEN r.error_message IS NULL AND r.status = 'failed' THEN SUBSTR(t.result, 1, 500) ELSE r.error_message END AS error_message,
|
|
247
|
+
r.blocked_by_task_id, r.blocked_by_state, r.created_at, r.completed_at
|
|
248
|
+
FROM scheduled_task_runs r
|
|
249
|
+
LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
|
|
250
|
+
WHERE r.schedule_id = ?1 AND r.network_id = ?2 ORDER BY r.created_at DESC, r.scheduled_for DESC LIMIT ?3`,
|
|
251
|
+
row.schedule_id, row.network_id, limit,
|
|
252
|
+
);
|
|
253
|
+
return { runs, ...failureSummary(row.schedule_id) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Shared PATCH semantics (REST PATCH and MCP schedule_update). Throws ScheduleInputError. */
|
|
257
|
+
export function patchSchedule(row: ScheduledRow, body: Record<string, unknown>): ScheduledRow {
|
|
258
|
+
// Cancellation/completion are terminal states. Editing must never become
|
|
259
|
+
// an implicit resurrection path (including by supplying status=active).
|
|
260
|
+
if (row.status === "cancelled") throw new ScheduleInputError("schedule_cancelled", 409);
|
|
261
|
+
if (row.status === "completed") throw new ScheduleInputError("schedule_completed", 409);
|
|
262
|
+
if (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision) throw new ScheduleInputError("revision_conflict", 409, { current_revision: row.revision });
|
|
263
|
+
try {
|
|
264
|
+
const name = body.name === undefined ? row.name : String(body.name).trim();
|
|
265
|
+
const content = body.task === undefined ? row.task_content : String(body.task).trim();
|
|
266
|
+
const priority = body.priority === undefined ? row.priority : String(body.priority);
|
|
267
|
+
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
268
|
+
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
269
|
+
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
270
|
+
const target = body.target_node_id === undefined ? { node_id: row.target_node_id, alias: row.target_alias } : validateTarget(row.network_id, body.target_node_id);
|
|
271
|
+
const parsed = body.schedule === undefined
|
|
272
|
+
? { spec: JSON.parse(row.schedule_json) as ScheduleSpec, timezone: body.timezone === undefined ? row.timezone : String(body.timezone) }
|
|
273
|
+
: parseScheduleSpec(body.schedule, body.timezone === undefined ? row.timezone : body.timezone);
|
|
274
|
+
if (!validTimezone(parsed.timezone)) throw new Error("invalid_timezone");
|
|
275
|
+
const requestedStatus = body.status === undefined ? row.status : String(body.status);
|
|
276
|
+
if (!new Set(["active", "paused"]).has(requestedStatus)) throw new Error("invalid_status");
|
|
277
|
+
const misfirePolicy = parseMisfirePolicy(body.misfire_policy, row.misfire_policy);
|
|
278
|
+
// Editing descriptive fields must not silently reset the schedule's
|
|
279
|
+
// cadence. Recompute only when the scheduling inputs change, when a
|
|
280
|
+
// paused schedule resumes, or when repairing an impossible active row
|
|
281
|
+
// with no next occurrence. The recompute uses the same DST-safe helper
|
|
282
|
+
// as creation and dispatch advancement.
|
|
283
|
+
const scheduleJson = JSON.stringify(parsed.spec);
|
|
284
|
+
const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone;
|
|
285
|
+
const resumed = row.status !== "active" && requestedStatus === "active";
|
|
286
|
+
const next = requestedStatus !== "active"
|
|
287
|
+
? null
|
|
288
|
+
: schedulingChanged || resumed || !row.next_run_at
|
|
289
|
+
? nextOccurrence(parsed.spec, parsed.timezone, new Date())
|
|
290
|
+
: new Date(row.next_run_at);
|
|
291
|
+
if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
|
|
292
|
+
// Runs no longer bump revision, so the scheduler may have advanced
|
|
293
|
+
// next_run_at after `row` was read. When cadence is preserved, keep the
|
|
294
|
+
// stored value instead of writing back the already-fired occurrence.
|
|
295
|
+
const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !resumed && !!row.next_run_at ? 1 : 0;
|
|
296
|
+
const updated = db.run(
|
|
297
|
+
`UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
|
|
298
|
+
priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
|
|
299
|
+
next_run_at = CASE WHEN ?14 = 1 AND next_run_at IS NOT NULL THEN next_run_at ELSE ?10 END,
|
|
300
|
+
misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
|
|
301
|
+
[name, target.node_id, target.alias, content, priority, parsed.spec.type, scheduleJson, parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision, keepStoredNext],
|
|
302
|
+
);
|
|
303
|
+
if (updated.changes !== 1) throw new ScheduleInputError("revision_conflict", 409);
|
|
304
|
+
return getScheduleRow(row.schedule_id)!;
|
|
305
|
+
} catch (e: any) {
|
|
306
|
+
if (e instanceof ScheduleInputError) throw e;
|
|
307
|
+
const code = String(e?.message || "invalid_schedule");
|
|
308
|
+
throw new ScheduleInputError(code, code === "target_node_not_found" ? 404 : 400);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
188
312
|
function scopedSchedule(scheduleId: string, scope: RestNetworkScope): ScheduledRow | null {
|
|
189
313
|
const params: unknown[] = [scheduleId];
|
|
190
314
|
let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`;
|
|
@@ -215,6 +339,25 @@ function stuckNoticeFor(row: ScheduledRow, reason: StuckNotice["reason"], blocke
|
|
|
215
339
|
};
|
|
216
340
|
}
|
|
217
341
|
|
|
342
|
+
/** The line an Agent-created schedule's task always starts with (board #733). */
|
|
343
|
+
export function agentSchedulePrefix(alias: string | null, nodeId: string): string {
|
|
344
|
+
return `[scheduled by agent ${alias ?? "?"} (${nodeId})]\n`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function agentCreatorDenial(row: ScheduledRow, target: AgentRef): { code: string; message: string } | null {
|
|
348
|
+
const creator = db.get<{ alias: string | null; owner_user_id: string | null; permission_mode: string | null }>(
|
|
349
|
+
"SELECT alias, owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2", row.created_by_node_id, row.network_id);
|
|
350
|
+
if (!creator) return { code: "creator_node_gone", message: "The node that created this schedule no longer exists in this network" };
|
|
351
|
+
const mode = creator.permission_mode === "readonly" || creator.permission_mode === "restricted" ? creator.permission_mode : "normal";
|
|
352
|
+
if (mode === "readonly") return { code: "creator_node_readonly", message: "The node that created this schedule is read-only" };
|
|
353
|
+
const verdict = dispatchVerdict({
|
|
354
|
+
networkId: row.network_id, nodeId: row.created_by_node_id, logKey: row.created_by_node_id!,
|
|
355
|
+
aliases: creator.alias ? [creator.alias] : [], ownerUserId: creator.owner_user_id, mode,
|
|
356
|
+
}, target);
|
|
357
|
+
if (verdict) return { code: "creator_access_revoked", message: `The node that created this schedule may not send_task to this agent now (${verdict.reason})` };
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
218
361
|
type DispatchEvent = { alias: string; networkId: string; taskId: string; priority: string; state: "delivered" | "queued" };
|
|
219
362
|
|
|
220
363
|
export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: string, advanceSchedule: boolean, advanceAfter = new Date()): { runId: string; taskId?: string; status: string; event?: DispatchEvent } {
|
|
@@ -302,15 +445,25 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
302
445
|
}
|
|
303
446
|
// 多用户 Agent 权限:排程是「建它的人」的委托。建的人后来被设成受限成员、而且没被授权
|
|
304
447
|
// 给目标 Agent 发任务时,不再代他派发(否则受限前建的排程会永远绕过授权)。
|
|
305
|
-
|
|
448
|
+
// Board #733 — an Agent-created schedule is that node's standing send_task: every run re-evaluates the
|
|
449
|
+
// creating node as it is NOW (exists, not read-only, current owner) with send_task's own dispatchVerdict.
|
|
450
|
+
const creatorDenial = row.created_by_node_id
|
|
451
|
+
? agentCreatorDenial(row, { alias: node.alias, nodeId: node.node_id })
|
|
452
|
+
: row.created_by && !canMessageAgent(row.created_by, row.network_id, { alias: node.alias, nodeId: node.node_id })
|
|
453
|
+
? { code: "creator_access_revoked", message: "The schedule creator is no longer allowed to message this agent" }
|
|
454
|
+
: null;
|
|
455
|
+
if (creatorDenial) {
|
|
306
456
|
finalStatus = "failed";
|
|
307
457
|
db.run(
|
|
308
|
-
"UPDATE scheduled_task_runs SET status = 'failed', error_code =
|
|
309
|
-
[runId],
|
|
458
|
+
"UPDATE scheduled_task_runs SET status = 'failed', error_code = ?1, error_message = ?2, completed_at = datetime('now') WHERE run_id = ?3",
|
|
459
|
+
[creatorDenial.code, creatorDenial.message, runId],
|
|
310
460
|
);
|
|
311
461
|
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
312
462
|
return;
|
|
313
463
|
}
|
|
464
|
+
const creatorNode = row.created_by_node_id
|
|
465
|
+
? db.get<{ alias: string | null }>("SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", row.created_by_node_id, row.network_id)
|
|
466
|
+
: null;
|
|
314
467
|
const lifecycle = assertNodeActive(node.alias, row.network_id);
|
|
315
468
|
if (!lifecycle.ok) {
|
|
316
469
|
finalStatus = "failed";
|
|
@@ -349,26 +502,32 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
349
502
|
);
|
|
350
503
|
const deliveryState: "delivered" | "queued" =
|
|
351
504
|
(session && session.status !== "offline") || hasSubscribers(node.alias, row.network_id) ? "delivered" : "queued";
|
|
505
|
+
// Board #733 — provenance is server-set: an Agent-created schedule names its node in meta and in a
|
|
506
|
+
// prefix the receiving agent always sees first, so it can never pass as a person's schedule.
|
|
352
507
|
const metaJson = JSON.stringify({
|
|
353
508
|
scheduled_task_id: row.schedule_id,
|
|
354
509
|
scheduled_run_id: runId,
|
|
355
510
|
scheduled_for: scheduledFor,
|
|
356
511
|
auth_origin: "hub_scheduler",
|
|
512
|
+
...(row.created_by_node_id ? { scheduled_by_node_id: row.created_by_node_id, scheduled_by_alias: creatorNode?.alias ?? null } : {}),
|
|
357
513
|
});
|
|
514
|
+
const taskContent = row.created_by_node_id
|
|
515
|
+
? `${agentSchedulePrefix(creatorNode?.alias ?? null, row.created_by_node_id)}${row.task_content}`
|
|
516
|
+
: row.task_content;
|
|
358
517
|
db.run(
|
|
359
518
|
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
360
519
|
VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, 'scheduler', 'reply', ?6, ?7)`,
|
|
361
|
-
[taskId, node.alias, node.node_id, row.priority,
|
|
520
|
+
[taskId, node.alias, node.node_id, row.priority, taskContent, row.network_id, metaJson],
|
|
362
521
|
);
|
|
363
522
|
db.run(
|
|
364
523
|
`INSERT INTO tasks (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, meta_json)
|
|
365
524
|
VALUES (?1, 'scheduler', ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', '+86400 seconds'), ?6, ?7)`,
|
|
366
|
-
[taskId, node.node_id, node.alias, row.priority,
|
|
525
|
+
[taskId, node.node_id, node.alias, row.priority, taskContent, row.network_id, metaJson],
|
|
367
526
|
);
|
|
368
527
|
// Dispatch is not completion. Keep the run open until the exact bound
|
|
369
528
|
// task reaches replied/failed/cancelled/expired; db.ts owns that mirror.
|
|
370
529
|
db.run("UPDATE scheduled_task_runs SET task_id = ?1, status = ?2, completed_at = NULL WHERE run_id = ?3", [taskId, deliveryState, runId]);
|
|
371
|
-
db.run(`UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3`, [sessionTaskPreview(
|
|
530
|
+
db.run(`UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3`, [sessionTaskPreview(taskContent), node.node_id, row.network_id]);
|
|
372
531
|
db.run("UPDATE scheduled_tasks SET target_alias = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3", [node.alias, scheduledFor, row.schedule_id]);
|
|
373
532
|
if (advanceSchedule) advance({ ...row, target_alias: node.alias }, scheduledFor, advanceAfter);
|
|
374
533
|
createdTaskId = taskId;
|
|
@@ -516,25 +675,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
516
675
|
if (!networkId) return jsonError("network_id_required", 400);
|
|
517
676
|
if (!writeAllowed(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
518
677
|
try {
|
|
519
|
-
const
|
|
520
|
-
const content = typeof body.task === "string" ? body.task.trim() : "";
|
|
521
|
-
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
522
|
-
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
523
|
-
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
524
|
-
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
525
|
-
const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
|
|
526
|
-
const target = validateTarget(networkId, body.target_node_id);
|
|
527
|
-
const { spec, timezone } = parseScheduleSpec(body.schedule, body.timezone);
|
|
528
|
-
const next = nextOccurrence(spec, timezone, new Date());
|
|
529
|
-
if (!next) throw new Error("schedule_has_no_future_occurrence");
|
|
530
|
-
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
531
|
-
db.run(
|
|
532
|
-
`INSERT INTO scheduled_tasks
|
|
533
|
-
(schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, priority, schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, next_run_at)
|
|
534
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'skip', ?12, ?13)`,
|
|
535
|
-
[scheduleId, networkId, ctx.auth?.userId ?? null, name, target.node_id, target.alias, content, priority, spec.type, JSON.stringify(spec), timezone, misfirePolicy, iso(next)],
|
|
536
|
-
);
|
|
537
|
-
const created = db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, scheduleId)!;
|
|
678
|
+
const created = insertSchedule(networkId, ctx.auth?.userId ?? null, null, validateScheduleCreate(networkId, body));
|
|
538
679
|
return Response.json({ ok: true, schedule: decodeRow(created) }, { status: 201 });
|
|
539
680
|
} catch (e: any) {
|
|
540
681
|
const code = String(e?.message || "invalid_schedule");
|
|
@@ -552,17 +693,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
552
693
|
|
|
553
694
|
if (sub === "runs" && req.method === "GET") {
|
|
554
695
|
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
|
|
555
|
-
|
|
556
|
-
// #523 — a task that ended `failed` leaves run.error_message empty; its reason is the task's reply text.
|
|
557
|
-
`SELECT r.run_id, r.schedule_id, r.scheduled_for, r.task_id, r.status, r.error_code,
|
|
558
|
-
CASE WHEN r.error_message IS NULL AND r.status = 'failed' THEN SUBSTR(t.result, 1, 500) ELSE r.error_message END AS error_message,
|
|
559
|
-
r.blocked_by_task_id, r.blocked_by_state, r.created_at, r.completed_at
|
|
560
|
-
FROM scheduled_task_runs r
|
|
561
|
-
LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
|
|
562
|
-
WHERE r.schedule_id = ?1 AND r.network_id = ?2 ORDER BY r.created_at DESC, r.scheduled_for DESC LIMIT ?3`,
|
|
563
|
-
row.schedule_id, row.network_id, limit,
|
|
564
|
-
);
|
|
565
|
-
return Response.json({ ok: true, runs, ...failureSummary(row.schedule_id) });
|
|
696
|
+
return Response.json({ ok: true, ...scheduleRuns(row, limit) });
|
|
566
697
|
}
|
|
567
698
|
if (!sub && req.method === "GET") return Response.json({ ok: true, schedule: decodeRow(row) });
|
|
568
699
|
if (!writeAllowed(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
@@ -588,64 +719,18 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
588
719
|
// "cancelled" between polls) should get the same outcome whether it's the
|
|
589
720
|
// first click or a retry.
|
|
590
721
|
if ((!sub && req.method === "DELETE") || (sub === "cancel" && req.method === "POST")) {
|
|
591
|
-
|
|
592
|
-
db.run("UPDATE scheduled_tasks SET status = 'cancelled', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1", [row.schedule_id]);
|
|
722
|
+
cancelSchedule(row);
|
|
593
723
|
return Response.json({ ok: true, status: "cancelled" });
|
|
594
724
|
}
|
|
595
725
|
|
|
596
726
|
if (!sub && req.method === "PATCH") {
|
|
597
727
|
let body: Record<string, unknown>;
|
|
598
728
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
599
|
-
// Cancellation/completion are terminal states. Editing must never become
|
|
600
|
-
// an implicit resurrection path (including by supplying status=active).
|
|
601
|
-
if (row.status === "cancelled") return jsonError("schedule_cancelled", 409);
|
|
602
|
-
if (row.status === "completed") return jsonError("schedule_completed", 409);
|
|
603
|
-
if (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision) return jsonError("revision_conflict", 409, { current_revision: row.revision });
|
|
604
729
|
try {
|
|
605
|
-
|
|
606
|
-
const content = body.task === undefined ? row.task_content : String(body.task).trim();
|
|
607
|
-
const priority = body.priority === undefined ? row.priority : String(body.priority);
|
|
608
|
-
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
609
|
-
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
610
|
-
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
611
|
-
const target = body.target_node_id === undefined ? { node_id: row.target_node_id, alias: row.target_alias } : validateTarget(row.network_id, body.target_node_id);
|
|
612
|
-
const parsed = body.schedule === undefined
|
|
613
|
-
? { spec: JSON.parse(row.schedule_json) as ScheduleSpec, timezone: body.timezone === undefined ? row.timezone : String(body.timezone) }
|
|
614
|
-
: parseScheduleSpec(body.schedule, body.timezone === undefined ? row.timezone : body.timezone);
|
|
615
|
-
if (!validTimezone(parsed.timezone)) throw new Error("invalid_timezone");
|
|
616
|
-
const requestedStatus = body.status === undefined ? row.status : String(body.status);
|
|
617
|
-
if (!new Set(["active", "paused"]).has(requestedStatus)) throw new Error("invalid_status");
|
|
618
|
-
const misfirePolicy = parseMisfirePolicy(body.misfire_policy, row.misfire_policy);
|
|
619
|
-
// Editing descriptive fields must not silently reset the schedule's
|
|
620
|
-
// cadence. Recompute only when the scheduling inputs change, when a
|
|
621
|
-
// paused schedule resumes, or when repairing an impossible active row
|
|
622
|
-
// with no next occurrence. The recompute uses the same DST-safe helper
|
|
623
|
-
// as creation and dispatch advancement.
|
|
624
|
-
const scheduleJson = JSON.stringify(parsed.spec);
|
|
625
|
-
const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone;
|
|
626
|
-
const resumed = row.status !== "active" && requestedStatus === "active";
|
|
627
|
-
const next = requestedStatus !== "active"
|
|
628
|
-
? null
|
|
629
|
-
: schedulingChanged || resumed || !row.next_run_at
|
|
630
|
-
? nextOccurrence(parsed.spec, parsed.timezone, new Date())
|
|
631
|
-
: new Date(row.next_run_at);
|
|
632
|
-
if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
|
|
633
|
-
// Runs no longer bump revision, so the scheduler may have advanced
|
|
634
|
-
// next_run_at after `row` was read. When cadence is preserved, keep the
|
|
635
|
-
// stored value instead of writing back the already-fired occurrence.
|
|
636
|
-
const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !resumed && !!row.next_run_at ? 1 : 0;
|
|
637
|
-
const updated = db.run(
|
|
638
|
-
`UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
|
|
639
|
-
priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
|
|
640
|
-
next_run_at = CASE WHEN ?14 = 1 AND next_run_at IS NOT NULL THEN next_run_at ELSE ?10 END,
|
|
641
|
-
misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
|
|
642
|
-
[name, target.node_id, target.alias, content, priority, parsed.spec.type, scheduleJson, parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision, keepStoredNext],
|
|
643
|
-
);
|
|
644
|
-
if (updated.changes !== 1) return jsonError("revision_conflict", 409);
|
|
645
|
-
return Response.json({ ok: true, schedule: decodeRow(db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, row.schedule_id)!) });
|
|
730
|
+
return Response.json({ ok: true, schedule: decodeRow(patchSchedule(row, body)) });
|
|
646
731
|
} catch (e: any) {
|
|
647
|
-
|
|
648
|
-
|
|
732
|
+
if (e instanceof ScheduleInputError) return jsonError(e.code, e.status, e.extra);
|
|
733
|
+
throw e;
|
|
649
734
|
}
|
|
650
735
|
}
|
|
651
736
|
return jsonError("method_not_allowed", 405);
|
package/src/server.ts
CHANGED
|
@@ -78,6 +78,7 @@ import { resolveRestFromSession } from "./rest-identity.js";
|
|
|
78
78
|
import { resolveNodeCaller } from "./create-node.js";
|
|
79
79
|
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
80
80
|
import { diagnoseTask } from "./task-diagnostic.js";
|
|
81
|
+
import { listTaskLateReplies } from "./task-late-replies.js";
|
|
81
82
|
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
82
83
|
import { startDueReminderTimer } from "./requirement-due-reminders.js";
|
|
83
84
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
@@ -3969,7 +3970,9 @@ return Bun.serve({
|
|
|
3969
3970
|
const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
|
|
3970
3971
|
const alias = url.searchParams.get("alias")?.trim() || null;
|
|
3971
3972
|
const params: any[] = [since];
|
|
3972
|
-
let sql =
|
|
3973
|
+
let sql = `SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, acked, created_at, network_id,
|
|
3974
|
+
CASE WHEN EXISTS(SELECT 1 FROM task_late_replies lr WHERE lr.inbox_id=inbox.id) THEN 1 ELSE 0 END AS late
|
|
3975
|
+
FROM inbox WHERE created_at >= ?1`;
|
|
3973
3976
|
if (alias) {
|
|
3974
3977
|
params.push(alias);
|
|
3975
3978
|
sql += ` AND session_name = ?${params.length}`;
|
|
@@ -3977,7 +3980,10 @@ return Bun.serve({
|
|
|
3977
3980
|
sql = addOwnTrafficScope(sql, params, restScope, { from: "from_session", to: "session_name" });
|
|
3978
3981
|
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
3979
3982
|
params.push(limit);
|
|
3980
|
-
const rows = db.all(sql, ...params)
|
|
3983
|
+
const rows = db.all<any>(sql, ...params).map((row) => {
|
|
3984
|
+
const { late, ...rest } = row;
|
|
3985
|
+
return { ...rest, ...(late === 1 || late === true ? { late: true } : {}) };
|
|
3986
|
+
});
|
|
3981
3987
|
|
|
3982
3988
|
let pendingCount: number | undefined;
|
|
3983
3989
|
if (alias) {
|
|
@@ -4819,6 +4825,7 @@ return Bun.serve({
|
|
|
4819
4825
|
liveSseConnections,
|
|
4820
4826
|
targetEverReportedRuntimeEvidence,
|
|
4821
4827
|
});
|
|
4828
|
+
task.late_replies = listTaskLateReplies(db, taskId, taskNetworkId);
|
|
4822
4829
|
return withCors(req, Response.json({ ok: true, task, diagnostic }));
|
|
4823
4830
|
}
|
|
4824
4831
|
|
|
@@ -4865,7 +4872,15 @@ return Bun.serve({
|
|
|
4865
4872
|
sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
|
|
4866
4873
|
params.push(limit);
|
|
4867
4874
|
|
|
4868
|
-
const rows = db.all(sql, ...params);
|
|
4875
|
+
const rows = db.all<any>(sql, ...params);
|
|
4876
|
+
// Existing desktop clients implement their one-task detail read as
|
|
4877
|
+
// `/api/tasks?task_id=…&limit=1`. Keep broad list responses lean, but
|
|
4878
|
+
// enrich that exact-detail shape as well as `/api/tasks/:id`.
|
|
4879
|
+
if (taskId) {
|
|
4880
|
+
for (const row of rows) {
|
|
4881
|
+
row.late_replies = listTaskLateReplies(db, String(row.task_id), typeof row.network_id === "string" ? row.network_id : null);
|
|
4882
|
+
}
|
|
4883
|
+
}
|
|
4869
4884
|
if (skipStats) {
|
|
4870
4885
|
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
|
|
4871
4886
|
}
|
|
@@ -17,27 +17,37 @@ import {
|
|
|
17
17
|
|
|
18
18
|
const HUB_PATH = join(import.meta.dir, "reserved-env.ts");
|
|
19
19
|
const DAEMON_PATH = join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "reserved-env.ts");
|
|
20
|
+
const CLI_PATH = join(import.meta.dir, "..", "..", "..", "agent-network", "src", "shared", "reserved-env.ts");
|
|
20
21
|
|
|
21
22
|
describe("RFC-026 v4 §4.4.7 G9 — hub/daemon reserved-env drift guard", () => {
|
|
22
23
|
test("byte-identical source files", () => {
|
|
23
24
|
const hub = readFileSync(HUB_PATH, "utf-8");
|
|
24
25
|
const daemon = readFileSync(DAEMON_PATH, "utf-8");
|
|
26
|
+
const cli = readFileSync(CLI_PATH, "utf-8");
|
|
25
27
|
expect(daemon).toBe(hub);
|
|
28
|
+
expect(cli).toBe(hub);
|
|
26
29
|
});
|
|
27
30
|
|
|
28
31
|
test("imported constants are set-equal at runtime", async () => {
|
|
29
32
|
// Use a dynamic file import path so this test breaks if the file
|
|
30
33
|
// moves but still references the daemon copy.
|
|
31
34
|
const daemonMod = await import(DAEMON_PATH);
|
|
35
|
+
const cliMod = await import(CLI_PATH);
|
|
32
36
|
const daemonExact = daemonMod.RESERVED_ENV_KEYS_EXACT as Set<string>;
|
|
33
37
|
const daemonPrefixes = daemonMod.RESERVED_ENV_PREFIXES as ReadonlyArray<string>;
|
|
38
|
+
const cliExact = cliMod.RESERVED_ENV_KEYS_EXACT as Set<string>;
|
|
39
|
+
const cliPrefixes = cliMod.RESERVED_ENV_PREFIXES as ReadonlyArray<string>;
|
|
34
40
|
|
|
35
41
|
// EXACT set equality
|
|
36
42
|
expect(daemonExact.size).toBe(HUB_EXACT.size);
|
|
37
43
|
for (const k of HUB_EXACT) expect(daemonExact.has(k)).toBe(true);
|
|
38
44
|
for (const k of daemonExact) expect(HUB_EXACT.has(k)).toBe(true);
|
|
45
|
+
expect(cliExact.size).toBe(HUB_EXACT.size);
|
|
46
|
+
for (const k of HUB_EXACT) expect(cliExact.has(k)).toBe(true);
|
|
47
|
+
for (const k of cliExact) expect(HUB_EXACT.has(k)).toBe(true);
|
|
39
48
|
|
|
40
49
|
// PREFIX array order-independent equality
|
|
41
50
|
expect(new Set(daemonPrefixes)).toEqual(new Set(HUB_PREFIXES));
|
|
51
|
+
expect(new Set(cliPrefixes)).toEqual(new Set(HUB_PREFIXES));
|
|
42
52
|
});
|
|
43
53
|
});
|