clay-server 4.1.0-beta.8 → 4.1.0-beta.9

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.
@@ -37,6 +37,7 @@ function createPendingMessageQueue(opts) {
37
37
  for (var i = 0; i < parsed.items.length; i++) {
38
38
  var item = parsed.items[i];
39
39
  if (!item || typeof item.id !== "string" || typeof item.sessionKey !== "string" || typeof item.projectSlug !== "string" || typeof item.actorId !== "string" || !item.message || ["pending", "claimed", "consumed", "cancelled", "failed"].indexOf(item.state) < 0) return false;
40
+ if (item.schedule !== undefined && (!item.schedule || typeof item.schedule !== "object" || typeof item.schedule.jobId !== "string" || typeof item.schedule.revision !== "string" || typeof item.schedule.ready !== "boolean" || !Number.isFinite(Number(item.schedule.notBefore)))) return false;
40
41
  }
41
42
  return true;
42
43
  }
@@ -113,30 +114,42 @@ function createPendingMessageQueue(opts) {
113
114
  for (var i = 0; i < data.items.length; i++) if (sameTarget(data.items[i], session) && (data.items[i].state === "pending" || data.items[i].state === "claimed")) return true;
114
115
  return false;
115
116
  }
116
- function admit(session, message, actor) {
117
+ function admitInternal(session, message, actor, trustedSchedule) {
117
118
  if (loadError) return result(false, loadError, session, actor);
118
119
  if (!session || !canMutate(session, actor, null)) return result(false, "Not authorized", session, actor);
119
120
  var validationError = validateMessage(message);
120
121
  if (validationError) return result(false, validationError, session, actor);
122
+ var clientId = message.clientMessageId ? String(message.clientMessageId) : null;
123
+ var duplicate = inspect(session, actor, clientId);
124
+ if (duplicate) {
125
+ var duplicateResult = result(duplicate.state !== "cancelled" && duplicate.state !== "failed", duplicate.state === "cancelled" ? "Message was cancelled" : duplicate.state === "failed" ? "Message delivery failed" : null, session, actor);
126
+ duplicateResult.item = duplicate;
127
+ return duplicateResult;
128
+ }
121
129
  var activeCount = 0;
122
130
  for (var activeIndex = 0; activeIndex < data.items.length; activeIndex++) {
123
131
  if (sameTarget(data.items[activeIndex], session) && (data.items[activeIndex].state === "pending" || data.items[activeIndex].state === "claimed")) activeCount++;
124
132
  }
125
133
  if (activeCount >= MAX_ACTIVE_ITEMS) return result(false, "Pending message queue is full", session, actor);
126
- var clientId = message.clientMessageId ? String(message.clientMessageId) : null;
127
- var duplicate = inspect(session, actor, clientId);
128
- if (duplicate) return result(duplicate.state !== "cancelled" && duplicate.state !== "failed", duplicate.state === "cancelled" ? "Message was cancelled" : duplicate.state === "failed" ? "Message delivery failed" : null, session, actor);
129
134
  var before = copy(data);
130
135
  var storedMessage = copy(message);
136
+ delete storedMessage.schedule;
131
137
  if (Object.prototype.hasOwnProperty.call(storedMessage, "images")) storedMessage.images = sanitizeImages(storedMessage.images);
132
138
  var item = { id: "pm_" + data.nextId++, sessionKey: key(session), sessionLocalId: session.localId, projectSlug: opts.slug, actorId: actorId(actor), actorName: actor && (actor.displayName || actor.username || actor.name) ? String(actor.displayName || actor.username || actor.name).slice(0, 120) : null, clientMessageId: clientId, message: storedMessage, createdAt: Date.now(), state: "pending" };
139
+ if (trustedSchedule) item.schedule = copy(trustedSchedule);
133
140
  delete item.message._pendingConsumed;
141
+ delete item.message._scheduledJobId;
142
+ delete item.message._scheduledRevision;
143
+ delete item.message._scheduledNotBefore;
134
144
  data.items.push(item);
135
145
  data.revision++;
136
146
  try { persist(); } catch (e) { restore(before); return result(false, "Queue storage failed", session, actor); }
137
147
  broadcast(session, { type: "pending_message_queued", sessionId: session.localId, projectSlug: opts.slug, paused: isPaused(session), item: copy(item), revision: data.revision });
138
- return result(true, null, session, actor);
148
+ var admitted = result(true, null, session, actor);
149
+ admitted.item = copy(item);
150
+ return admitted;
139
151
  }
152
+ function admit(session, message, actor) { return admitInternal(session, message, actor, null); }
140
153
  function mutate(session, actor, id, expectedRevision, expectedSessionId, operation, allowedStates) {
141
154
  if (loadError) return result(false, loadError, session, actor);
142
155
  if (!session || session.localId !== expectedSessionId || !authorize(session, actor)) return result(false, "Session changed", session, actor);
@@ -151,6 +164,7 @@ function createPendingMessageQueue(opts) {
151
164
  try { persist(); } catch (e) { restore(before); return result(false, "Queue storage failed", session, actor); }
152
165
  if (data.items[index].state === "cancelled" && session._pendingMessageClaimId === data.items[index].id) delete session._pendingMessageClaimId;
153
166
  broadcast(session, { type: "pending_message_state", sessionId: session.localId, projectSlug: opts.slug, paused: isPaused(session), revision: data.revision, items: visible(session, actor) });
167
+ if (data.items[index].state === "cancelled" && typeof opts.onItemCancelled === "function") opts.onItemCancelled(copy(data.items[index]));
154
168
  return result(true, null, session, actor);
155
169
  }
156
170
  function edit(session, actor, id, revision, expectedSessionId, message) {
@@ -166,8 +180,9 @@ function createPendingMessageQueue(opts) {
166
180
  var validationError = validateMessage(proposed);
167
181
  if (validationError) return result(false, validationError, session, actor);
168
182
  }
183
+ if (current && (!session || session.localId !== expectedSessionId || !authorize(session, actor) || revision !== data.revision || current.state !== "pending" || !canMutate(session, actor, current))) return result(false, "Pending message not found", session, actor);
169
184
  return mutate(session, actor, id, revision, expectedSessionId, function (items, index) {
170
- var nextMessage = Object.assign({}, items[index].message, { text: text });
185
+ var nextMessage = Object.assign({}, items[index].message, proposed || { text: text });
171
186
  if (message && Object.prototype.hasOwnProperty.call(message, "images")) nextMessage.images = sanitizeImages(message.images);
172
187
  items[index].message = nextMessage;
173
188
  });
@@ -229,6 +244,7 @@ function createPendingMessageQueue(opts) {
229
244
  try { persist(); } catch (e) { restore(before); return false; }
230
245
  if (session._pendingMessageClaimId === id) delete session._pendingMessageClaimId;
231
246
  broadcast(session, { type: ok === "release" ? "pending_message_state" : "pending_message_consumed", sessionId: session.localId, projectSlug: opts.slug, paused: isPaused(session), id: id, state: data.items[index].state, items: ok === "release" ? visible(session, { id: data.items[index].actorId }) : undefined, revision: data.revision });
247
+ if (ok === true && typeof opts.onItemConsumed === "function") opts.onItemConsumed(copy(data.items[index]));
232
248
  return true;
233
249
  }
234
250
  function consumeOne(session, dispatch) {
@@ -237,6 +253,7 @@ function createPendingMessageQueue(opts) {
237
253
  for (var i = 0; i < data.items.length; i++) {
238
254
  var item = data.items[i];
239
255
  if (!sameTarget(item, session) || item.state !== "pending") continue;
256
+ if (item.schedule && !item.schedule.ready) continue;
240
257
  if (!canMutate(session, { id: item.actorId }, item)) continue;
241
258
  var before = copy(data);
242
259
  item.state = "claimed";
@@ -258,7 +275,54 @@ function createPendingMessageQueue(opts) {
258
275
  for (var i = 0; i < data.items.length; i++) if (data.items[i].id === id && sameTarget(data.items[i], session) && data.items[i].state === "claimed") return true;
259
276
  return false;
260
277
  }
261
- return { admit: admit, edit: edit, cancel: cancel, reorder: reorder, pause: pause, resume: resume, consumeOne: consumeOne, hydrate: hydrate, inspect: inspect, hasActive: hasActive, isClaimed: isClaimed, isPaused: isPaused, list: visible, getRevision: function () { return data.revision; }, getLoadError: function () { return loadError; }, filePath: filePath };
278
+ function upsertScheduled(session, message, actor, trustedSchedule) {
279
+ message = copy(message);
280
+ var schedule = trustedSchedule || message.schedule;
281
+ delete message.schedule;
282
+ if (!schedule || typeof schedule.jobId !== "string" || typeof schedule.revision !== "string" || typeof schedule.ready !== "boolean" || !Number.isFinite(Number(schedule.notBefore))) return result(false, "Invalid scheduled queue metadata", session, actor);
283
+ var validationError = validateMessage(message);
284
+ if (validationError) return result(false, validationError, session, actor);
285
+ if (Object.prototype.hasOwnProperty.call(message, "images")) message.images = sanitizeImages(message.images);
286
+ delete message._pendingConsumed;
287
+ delete message._scheduledJobId;
288
+ delete message._scheduledRevision;
289
+ delete message._scheduledNotBefore;
290
+ for (var i = 0; i < data.items.length; i++) {
291
+ if (sameTarget(data.items[i], session) && data.items[i].schedule && data.items[i].schedule.jobId === schedule.jobId && data.items[i].state !== "pending") return result(false, "Scheduled queue item is terminal", session, actor);
292
+ if (sameTarget(data.items[i], session) && data.items[i].schedule && data.items[i].schedule.jobId === schedule.jobId && data.items[i].state === "pending") {
293
+ if (!authorize(session, actor) || !canMutate(session, actor, data.items[i])) return result(false, "Not authorized", session, actor);
294
+ var before = copy(data);
295
+ data.items[i].message = copy(message);
296
+ data.items[i].schedule = copy(schedule);
297
+ data.revision++;
298
+ try { persist(); } catch (error) { restore(before); return result(false, "Queue storage failed", session, actor); }
299
+ broadcast(session, { type: "pending_message_state", sessionId: session.localId, projectSlug: opts.slug, paused: isPaused(session), revision: data.revision, items: visible(session, actor) });
300
+ return { ok: true, item: copy(data.items[i]), revision: data.revision };
301
+ }
302
+ }
303
+ return admitInternal(session, message, actor, schedule);
304
+ }
305
+ function cancelScheduled(session, jobId, actor) {
306
+ for (var i = 0; i < data.items.length; i++) {
307
+ if (sameTarget(data.items[i], session) && data.items[i].schedule && data.items[i].schedule.jobId === jobId && (data.items[i].state === "pending" || data.items[i].state === "claimed")) return cancel(session, actor, data.items[i].id, data.revision, session.localId);
308
+ }
309
+ return false;
310
+ }
311
+ function releaseScheduled(session, jobId, revision) {
312
+ for (var i = 0; i < data.items.length; i++) {
313
+ var item = data.items[i];
314
+ if (!sameTarget(item, session) || !item.schedule || item.schedule.jobId !== jobId || item.schedule.revision !== revision) continue;
315
+ if (item.state !== "pending" || item.schedule.ready) return item.state === "pending" ? copy(item) : null;
316
+ var before = copy(data);
317
+ item.schedule.ready = true;
318
+ data.revision++;
319
+ try { persist(); } catch (error) { restore(before); return null; }
320
+ broadcast(session, { type: "pending_message_state", sessionId: session.localId, projectSlug: opts.slug, paused: isPaused(session), revision: data.revision, items: visible(session, { id: item.actorId }) });
321
+ return copy(item);
322
+ }
323
+ return null;
324
+ }
325
+ return { admit: admit, upsertScheduled: upsertScheduled, releaseScheduled: releaseScheduled, cancelScheduled: cancelScheduled, edit: edit, cancel: cancel, reorder: reorder, pause: pause, resume: resume, consumeOne: consumeOne, hydrate: hydrate, inspect: inspect, hasActive: hasActive, isClaimed: isClaimed, isPaused: isPaused, list: visible, getRevision: function () { return data.revision; }, getLoadError: function () { return loadError; }, filePath: filePath };
262
326
  }
263
327
 
264
328
  module.exports = { createPendingMessageQueue: createPendingMessageQueue };
@@ -2,19 +2,19 @@ var crypto = require("crypto");
2
2
  var sessionProvenance = require("./session-provenance");
3
3
 
4
4
  var LOCAL_OWNER = "local-user";
5
-
5
+ var REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
6
6
  function attachProjectScheduledMessages(ctx) {
7
7
  var scheduler = ctx.scheduler;
8
8
  var slug = ctx.slug;
9
9
  var sm = ctx.sm;
10
- var sdk = ctx.sdk;
11
10
  var users = ctx.usersModule;
11
+ var pendingMessageQueue = ctx.pendingMessageQueue;
12
+ var consumePendingMessage = ctx.consumePendingMessage;
12
13
  var type = "project-chat-message:" + slug;
13
14
  var makeRevision = ctx.makeRevision || function () { return crypto.randomUUID(); };
14
15
  var now = ctx.now || Date.now;
15
16
  var unregister = null;
16
17
  var stopped = false;
17
-
18
18
  function findSession(cliSessionId) {
19
19
  var found = null;
20
20
  sm.sessions.forEach(function (session) {
@@ -22,7 +22,6 @@ function attachProjectScheduledMessages(ctx) {
22
22
  });
23
23
  return found;
24
24
  }
25
-
26
25
  function latestJob(session) {
27
26
  if (!scheduler || !session || !session.cliSessionId) return null;
28
27
  var jobs = scheduler.listJobs();
@@ -31,7 +30,6 @@ function attachProjectScheduledMessages(ctx) {
31
30
  }
32
31
  return null;
33
32
  }
34
-
35
33
  function pendingJob(session) {
36
34
  if (!scheduler || !session || !session.cliSessionId) return null;
37
35
  if (session.scheduledMessage && session.scheduledMessage.jobId) {
@@ -45,21 +43,43 @@ function attachProjectScheduledMessages(ctx) {
45
43
  }
46
44
  return null;
47
45
  }
48
-
49
- function projection(job) {
46
+ function projection(job, queueItem) {
50
47
  if (!job || job.state !== "queued") return null;
51
48
  return {
52
49
  jobId: job.id,
53
50
  revision: job.payload.revision,
54
- text: job.payload.text,
51
+ text: queueItem && queueItem.message ? queueItem.message.text : "",
55
52
  resetsAt: job.runAt,
56
53
  scheduledAt: job.payload.scheduledAt,
57
54
  };
58
55
  }
59
-
56
+ function queueActor(actorId) {
57
+ return users.isMultiUser() ? users.findUserById(actorId) : { id: "default" };
58
+ }
59
+ function queueItemForJob(session, job, actorId) {
60
+ if (!pendingMessageQueue || !session || !job) return null;
61
+ var actor = queueActor(actorId || (job.payload && job.payload.actorId));
62
+ var items = pendingMessageQueue.list(session, actor);
63
+ for (var i = 0; i < items.length; i++) {
64
+ if (items[i].schedule && items[i].schedule.jobId === job.id && items[i].schedule.revision === job.payload.revision) return items[i];
65
+ }
66
+ return null;
67
+ }
68
+ function validateQueueControl(session, job, actorId, correlation, requireQueueRevision) {
69
+ if (!pendingMessageQueue) return null;
70
+ if (requireQueueRevision && (!correlation || correlation.queueRevision == null || Number(correlation.queueRevision) !== pendingMessageQueue.getRevision())) {
71
+ throw new Error("The scheduled-message queue is stale.");
72
+ }
73
+ var actor = queueActor(actorId || (job.payload && job.payload.actorId));
74
+ var item = queueItemForJob(session, job, actorId);
75
+ if (!item || item.actorId !== actor.id || ["cancelled", "consumed", "failed"].indexOf(item.state) >= 0) {
76
+ throw new Error("The scheduled queue item is no longer available.");
77
+ }
78
+ return item;
79
+ }
60
80
  function sendState(session, targetWs, sendFn) {
61
81
  var job = pendingJob(session);
62
- session.scheduledMessage = projection(job);
82
+ session.scheduledMessage = projection(job, queueItemForJob(session, job, job && job.payload && job.payload.actorId));
63
83
  var latest = latestJob(session);
64
84
  var interrupted = !job && latest && latest.state === "interrupted" ? {
65
85
  jobId: latest.id,
@@ -72,20 +92,17 @@ function attachProjectScheduledMessages(ctx) {
72
92
  else ctx.sendToSession(session.localId, message);
73
93
  return message;
74
94
  }
75
-
76
95
  function visibleError(session, targetWs, text) {
77
96
  var message = { type: "error", text: text };
78
97
  if (targetWs) ctx.sendTo(targetWs, message);
79
98
  else if (session) ctx.sendToSession(session.localId, message);
80
99
  }
81
-
82
100
  function ownerFor(session) {
83
101
  if (!users.isMultiUser()) return LOCAL_OWNER;
84
102
  if (!session.ownerId) throw new Error("Scheduled messages require a session owner.");
85
103
  if (!users.findUserById(session.ownerId)) throw new Error("The session owner no longer exists.");
86
104
  return session.ownerId;
87
105
  }
88
-
89
106
  function authorize(session, actorId) {
90
107
  if (!session || session.destroying) throw new Error("The session is no longer available.");
91
108
  if (sm.sessions.get(session.localId) !== session) throw new Error("The session is no longer live.");
@@ -106,7 +123,6 @@ function attachProjectScheduledMessages(ctx) {
106
123
  }
107
124
  return ownerId;
108
125
  }
109
-
110
126
  function buildSpec(session, text, resetsAt, actorId, revision) {
111
127
  var timestamp = now();
112
128
  var runAt = resetsAt <= timestamp ? timestamp + 5000 : resetsAt + 60000;
@@ -119,6 +135,8 @@ function attachProjectScheduledMessages(ctx) {
119
135
  target: { kind: "project-session", id: session.cliSessionId },
120
136
  payload: {
121
137
  text: text,
138
+ hasAttachments: false,
139
+ queueAdmissionRequired: true,
122
140
  cliSessionId: session.cliSessionId,
123
141
  actorId: actorId || LOCAL_OWNER,
124
142
  sessionOwnerId: ownerId,
@@ -129,27 +147,117 @@ function attachProjectScheduledMessages(ctx) {
129
147
  runAt: runAt,
130
148
  };
131
149
  }
150
+ function requestJobs(session, actorId, requestId) {
151
+ if (!scheduler || !session || !requestId) return [];
152
+ var jobs = scheduler.listJobs();
153
+ return jobs.filter(function (job) {
154
+ return job.type === type && job.projectId === slug && job.target && job.target.id === session.cliSessionId &&
155
+ job.payload && job.payload.admissionRequestId === requestId && job.payload.actorId === actorId;
156
+ });
157
+ }
158
+ function requestIdempotencyKey(session, actorId, requestId) {
159
+ var base = "project-message:" + slug + ":" + session.cliSessionId + ":actor:" + actorId + ":request:" + requestId;
160
+ var jobs = requestJobs(session, actorId, requestId);
161
+ var retryCount = 0;
162
+ for (var i = 0; i < jobs.length; i++) if (jobs[i].state === "cancelled" && jobs[i].cancelledReason === "queue_admission_failed") retryCount++;
163
+ var key = crypto.createHash("sha256").update(base).digest("hex");
164
+ return "project-message-request:" + key + (retryCount ? ":retry-" + retryCount : "");
165
+ }
166
+ function sendAdmissionResult(targetWs, session, actorId, requestId, job, error, transportAccountId) {
167
+ if (!targetWs || !requestId) return;
168
+ ctx.sendTo(targetWs, {
169
+ type: "schedule_message_result",
170
+ requestId: requestId,
171
+ projectSlug: slug,
172
+ sessionId: session ? session.localId : null,
173
+ accountId: transportAccountId || actorId || "default",
174
+ ok: !error,
175
+ error: error || null,
176
+ scheduledMessage: job ? projection(job, queueItemForJob(session, job, actorId)) : null,
177
+ });
178
+ }
132
179
 
133
- function schedule(session, text, resetsAt, actorUser, targetWs) {
180
+ function schedule(session, text, resetsAt, actorUser, targetWs, attachments, requestId, transportAccountId) {
181
+ var actorId = users.isMultiUser() ? (actorUser && actorUser.id) : LOCAL_OWNER;
134
182
  if (!scheduler || stopped) {
183
+ sendAdmissionResult(targetWs, session, actorId, requestId, null, "Scheduled messages are temporarily unavailable.", transportAccountId);
135
184
  visibleError(session, targetWs, "Scheduled messages are temporarily unavailable.");
136
185
  return null;
137
186
  }
138
- if (typeof text !== "string" || !text.trim() || !isFinite(Number(resetsAt))) {
187
+ var images = attachments && Array.isArray(attachments.images) ? attachments.images : [];
188
+ var pastes = attachments && Array.isArray(attachments.pastes) ? attachments.pastes : [];
189
+ if (attachments && Object.prototype.hasOwnProperty.call(attachments, "images") && !Array.isArray(attachments.images)) {
190
+ sendAdmissionResult(targetWs, session, actorId, requestId, null, "Invalid image attachments.", transportAccountId);
191
+ return null;
192
+ }
193
+ if (attachments && Object.prototype.hasOwnProperty.call(attachments, "pastes") && !Array.isArray(attachments.pastes)) {
194
+ sendAdmissionResult(targetWs, session, actorId, requestId, null, "Invalid paste attachments.", transportAccountId);
195
+ return null;
196
+ }
197
+ if (requestId && (typeof requestId !== "string" || !REQUEST_ID_RE.test(requestId))) {
198
+ sendAdmissionResult(targetWs, session, actorId, requestId, null, "Invalid schedule request identifier.", transportAccountId);
199
+ return null;
200
+ }
201
+ if (typeof text !== "string" || (!text.trim() && !images.length && !pastes.length) || !isFinite(Number(resetsAt))) {
202
+ sendAdmissionResult(targetWs, session, actorId, requestId, null, "A valid scheduled message and reset time are required.", transportAccountId);
139
203
  visibleError(session, targetWs, "A valid scheduled message and reset time are required.");
140
204
  return null;
141
205
  }
142
206
  try {
143
- var actorId = users.isMultiUser() ? (actorUser && actorUser.id) : LOCAL_OWNER;
207
+ // Authorization is deliberately performed before looking up a durable
208
+ // request association, so replay cannot disclose another account's job.
209
+ authorize(session, actorId);
210
+ var priorJobs = requestJobs(session, actorId, requestId);
211
+ var prior = priorJobs.length ? priorJobs[priorJobs.length - 1] : null;
212
+ if (prior && (prior.state !== "cancelled" || prior.cancelledReason !== "queue_admission_failed")) {
213
+ if (prior.state === "queued") {
214
+ var priorItem = queueItemForJob(session, prior, actorId);
215
+ if (!priorItem) throw new Error("Scheduled queue item is missing; refusing to replay request.");
216
+ if (["cancelled", "consumed", "failed"].indexOf(priorItem.state) >= 0) throw new Error("Scheduled queue item is no longer available; refusing to replay request.");
217
+ }
218
+ sendAdmissionResult(targetWs, session, actorId, requestId, prior, null, transportAccountId);
219
+ return prior;
220
+ }
144
221
  var revision = makeRevision();
145
222
  var spec = buildSpec(session, text, Number(resetsAt), actorId, revision);
223
+ if (requestId) {
224
+ spec.idempotencyKey = requestIdempotencyKey(session, actorId, requestId);
225
+ spec.payload.admissionRequestId = requestId;
226
+ spec.payload.admissionAccountId = actorId;
227
+ }
228
+ spec.payload.hasAttachments = images.length > 0 || pastes.length > 0;
146
229
  var current = pendingJob(session);
230
+ if (current && requestId && (!current.payload || current.payload.admissionRequestId !== requestId)) {
231
+ throw new Error("Another scheduled message is already pending.");
232
+ }
233
+ if (current && pendingMessageQueue) validateQueueControl(session, current, actorId, null, false);
147
234
  var job = current ? scheduler.replaceQueued(current.id, spec) : scheduler.enqueue(spec);
148
- session.scheduledMessage = projection(job);
235
+ if (pendingMessageQueue && job) {
236
+ var queuedScheduled = pendingMessageQueue.upsertScheduled(session, {
237
+ type: "message",
238
+ text: text,
239
+ images: images,
240
+ pastes: pastes,
241
+ clientMessageId: requestId ? "scheduled-request-" + requestId : "scheduled-" + job.id,
242
+ schedule: { jobId: job.id, revision: job.payload.revision, notBefore: job.runAt, ready: false },
243
+ }, queueActor(actorId));
244
+ if (!queuedScheduled.ok) {
245
+ if (current) {
246
+ try { scheduler.replaceQueued(current.id, current); }
247
+ catch (rollbackError) { throw new Error((queuedScheduled.error || "Unable to queue scheduled message") + "; scheduler rollback failed: " + rollbackError.message); }
248
+ } else {
249
+ scheduler.cancel(job.id, "queue_admission_failed");
250
+ }
251
+ throw new Error(queuedScheduled.error || "Unable to queue scheduled message");
252
+ }
253
+ }
254
+ session.scheduledMessage = projection(job, queueItemForJob(session, job, job && job.payload && job.payload.actorId));
255
+ sendAdmissionResult(targetWs, session, actorId, requestId, job, null, transportAccountId);
149
256
  sm.sendAndRecord(session, Object.assign({ type: "scheduled_message_queued" }, session.scheduledMessage));
150
257
  return job;
151
258
  } catch (error) {
152
259
  if (session) session.rateLimitAutoContinuePending = false;
260
+ sendAdmissionResult(targetWs, session, actorId, requestId, null, error.message, transportAccountId);
153
261
  visibleError(session, targetWs, "Could not schedule the message: " + error.message);
154
262
  return null;
155
263
  }
@@ -157,7 +265,7 @@ function attachProjectScheduledMessages(ctx) {
157
265
 
158
266
  function requireCorrelation(job, correlation, targetWs) {
159
267
  if (!targetWs) return;
160
- if (!correlation || correlation.jobId !== job.id || correlation.revision !== job.payload.revision) {
268
+ if (!correlation || correlation.jobId !== job.id || correlation.revision !== job.payload.revision || correlation.queueRevision == null || !pendingMessageQueue || Number(correlation.queueRevision) !== pendingMessageQueue.getRevision()) {
161
269
  throw new Error("The scheduled-message control is stale.");
162
270
  }
163
271
  }
@@ -165,13 +273,18 @@ function attachProjectScheduledMessages(ctx) {
165
273
  function cancel(session, actorUser, targetWs, reason, correlation) {
166
274
  try {
167
275
  authorize(session, users.isMultiUser() ? (actorUser && actorUser.id) : LOCAL_OWNER);
168
- var job = pendingJob(session);
276
+ var job = pendingJob(session) || latestJob(session);
169
277
  if (!job) return false;
170
278
  requireCorrelation(job, correlation, targetWs);
171
- scheduler.cancel(job.id, reason || "cancelled_by_user");
279
+ if (pendingMessageQueue) validateQueueControl(session, job, actorUser && actorUser.id, correlation, !!targetWs);
280
+ if (pendingMessageQueue) {
281
+ var queueCancelled = pendingMessageQueue.cancelScheduled(session, job.id, queueActor(actorUser && actorUser.id));
282
+ if (!queueCancelled || queueCancelled.ok !== true) throw new Error((queueCancelled && queueCancelled.error) || "The scheduled queue item could not be cancelled.");
283
+ }
284
+ if (job.state === "queued" && !pendingMessageQueue) scheduler.cancel(job.id, reason || "cancelled_by_user");
172
285
  session.scheduledMessage = null;
173
286
  session.rateLimitAutoContinuePending = false;
174
- sm.sendAndRecord(session, { type: "scheduled_message_cancelled", jobId: job.id, revision: job.payload.revision });
287
+ if (!pendingMessageQueue) sm.sendAndRecord(session, { type: "scheduled_message_cancelled", jobId: job.id, revision: job.payload.revision });
175
288
  return true;
176
289
  } catch (error) {
177
290
  visibleError(session, targetWs, "Could not cancel the scheduled message: " + error.message);
@@ -185,11 +298,27 @@ function attachProjectScheduledMessages(ctx) {
185
298
  var job = pendingJob(session);
186
299
  if (!job) return false;
187
300
  requireCorrelation(job, correlation, targetWs);
301
+ var existing = validateQueueControl(session, job, actorUser && actorUser.id, correlation, !!targetWs);
188
302
  var updated = scheduler.replaceQueued(job.id, {
189
303
  runAt: now(),
190
304
  idempotencyKey: job.idempotencyKey + ":now",
191
305
  });
192
- session.scheduledMessage = projection(updated);
306
+ if (pendingMessageQueue) {
307
+ var queued = pendingMessageQueue.upsertScheduled(session, {
308
+ type: "message",
309
+ text: existing ? existing.message.text : updated.payload.text,
310
+ images: existing ? existing.message.images : undefined,
311
+ pastes: existing ? existing.message.pastes : undefined,
312
+ clientMessageId: existing ? existing.clientMessageId : "scheduled-" + updated.id,
313
+ schedule: { jobId: updated.id, revision: updated.payload.revision, notBefore: updated.runAt, ready: false },
314
+ }, queueActor(actorUser && actorUser.id));
315
+ if (!queued.ok) {
316
+ try { scheduler.replaceQueued(job.id, job); }
317
+ catch (rollbackError) { throw new Error((queued.error || "The scheduled queue item could not be updated.") + "; scheduler rollback failed: " + rollbackError.message); }
318
+ throw new Error(queued.error || "The scheduled queue item could not be updated.");
319
+ }
320
+ }
321
+ session.scheduledMessage = projection(updated, existing);
193
322
  scheduler.tick();
194
323
  return true;
195
324
  } catch (error) {
@@ -237,43 +366,94 @@ function attachProjectScheduledMessages(ctx) {
237
366
  var linuxUser = ctx.ensureProjectAccessForSession(session);
238
367
  if (ctx.osUsers && !linuxUser) throw new Error("The session owner OS identity is unavailable.");
239
368
  execution.recordMetadata({ projectSlug: slug, cliSessionId: session.cliSessionId, revision: job.payload.revision });
240
- execution.recordReceipt({ state: "dispatching", projectSlug: slug, cliSessionId: session.cliSessionId, revision: job.payload.revision, recordedAt: now() });
241
-
242
- var userMessage = {
243
- type: "user_message",
244
- text: job.payload.text,
245
- _ts: now(),
246
- scheduledJobId: job.id,
247
- scheduledRevision: job.payload.revision,
248
- };
249
- session.history.push(userMessage);
250
- sm.appendToSessionFile(session, userMessage);
251
- ctx.sendToSession(session.localId, userMessage);
252
- processingOwner = {};
253
- session._scheduledMessageProcessing = processingOwner;
254
- session.isProcessing = true;
255
- ctx.onProcessingChanged();
256
- ctx.sendToSession(session.localId, { type: "status", status: "processing" });
257
- var queryPromise = sdk.startQuery(session, job.payload.text, null, linuxUser);
258
- processingOwner.queryGeneration = session._queryGeneration;
259
- processingOwner.queryInstance = session.queryInstance;
260
- if (session.scheduledMessage && session.scheduledMessage.jobId === job.id) {
261
- session.scheduledMessage = null;
262
- session.rateLimitAutoContinuePending = false;
369
+ if (!pendingMessageQueue) throw new Error("Scheduled message queue is unavailable.");
370
+ var existing = queueItemForJob(session, job, job.payload.actorId);
371
+ if (existing && ["cancelled", "consumed", "failed"].indexOf(existing.state) >= 0) {
372
+ return { released: false, queueItemId: existing.id, cliSessionId: session.cliSessionId, revision: job.payload.revision };
263
373
  }
264
- sm.sendAndRecord(session, { type: "scheduled_message_sent", jobId: job.id, revision: job.payload.revision });
265
- sm.broadcastSessionList();
266
- await Promise.resolve(queryPromise);
267
- if (session._scheduledMessageProcessing === processingOwner) delete session._scheduledMessageProcessing;
268
- return { dispatched: true, cliSessionId: session.cliSessionId, revision: job.payload.revision };
374
+ if (!existing && ["interrupted", "completed"].indexOf(job.state) >= 0) {
375
+ return { released: false, queueItemId: null, cliSessionId: session.cliSessionId, revision: job.payload.revision };
376
+ }
377
+ var ensured = existing;
378
+ if (!ensured && job.payload.queueAdmissionRequired === true) throw new Error("Scheduled queue item is missing; refusing to recreate message payload.");
379
+ if (!ensured) ensured = pendingMessageQueue.upsertScheduled(session, {
380
+ type: "message", text: job.payload.text, clientMessageId: "scheduled-" + job.id,
381
+ schedule: { jobId: job.id, revision: job.payload.revision, notBefore: job.runAt, ready: false },
382
+ }, queueActor(job.payload.actorId));
383
+ if (!ensured || ensured.ok === false) throw new Error(ensured.error || "Scheduled queue handoff could not be prepared.");
384
+ var released = pendingMessageQueue.releaseScheduled(session, job.id, job.payload.revision);
385
+ if (!released) throw new Error("Scheduled message queue handoff could not be released.");
386
+ if (typeof consumePendingMessage === "function") setImmediate(function () { consumePendingMessage(session); });
387
+ return { released: true, queueItemId: released.id, cliSessionId: session.cliSessionId, revision: job.payload.revision };
269
388
  } catch (error) {
270
389
  failJob(session, job, error, processingOwner);
271
390
  throw error;
272
391
  }
273
392
  }
274
393
 
394
+ function onQueueConsumed(item) {
395
+ if (!item || !item.schedule || !scheduler) return false;
396
+ var job = scheduler.getJob(item.schedule.jobId);
397
+ var session = job && job.target ? findSession(job.target.id) : null;
398
+ if (!session) return false;
399
+ if (session.scheduledMessage && session.scheduledMessage.jobId === item.schedule.jobId) {
400
+ session.scheduledMessage = null;
401
+ session.rateLimitAutoContinuePending = false;
402
+ }
403
+ sm.sendAndRecord(session, { type: "scheduled_message_sent", jobId: job.id, revision: item.schedule.revision });
404
+ sm.broadcastSessionList();
405
+ return true;
406
+ }
407
+
408
+ function onQueueCancelled(item) {
409
+ if (!item || !item.schedule || !scheduler) return false;
410
+ var job = scheduler.getJob(item.schedule.jobId);
411
+ if (job && job.state === "queued") {
412
+ try { scheduler.cancel(job.id, "cancelled_by_user"); } catch (error) {}
413
+ }
414
+ var session = job && job.target ? findSession(job.target.id) : null;
415
+ if (session && session.scheduledMessage && session.scheduledMessage.jobId === item.schedule.jobId) {
416
+ session.scheduledMessage = null;
417
+ session.rateLimitAutoContinuePending = false;
418
+ sm.sendAndRecord(session, { type: "scheduled_message_cancelled", jobId: item.schedule.jobId, revision: item.schedule.revision });
419
+ }
420
+ return true;
421
+ }
422
+
423
+ function authorizePending(item, session) {
424
+ if (!item || !item.schedule || !scheduler || !session) return true;
425
+ var job = scheduler.getJob(item.schedule.jobId);
426
+ var expectedActorId = users.isMultiUser() ? job && job.payload && job.payload.actorId : "default";
427
+ if (!job || ["running", "completed", "interrupted"].indexOf(job.state) < 0 || !job.target || job.target.id !== session.cliSessionId ||
428
+ !job.payload || job.projectId !== slug || job.payload.revision !== item.schedule.revision || item.actorId !== expectedActorId ||
429
+ job.ownerId !== job.payload.sessionOwnerId) return false;
430
+ try { return authorize(session, job.payload.actorId) === job.ownerId; } catch (error) { return false; }
431
+ }
432
+
275
433
  function rehydrate() {
276
- sm.sessions.forEach(function (session) { session.scheduledMessage = projection(pendingJob(session)); });
434
+ sm.sessions.forEach(function (session) {
435
+ var pending = pendingJob(session);
436
+ session.scheduledMessage = projection(pending, queueItemForJob(session, pending, pending && pending.payload && pending.payload.actorId));
437
+ if (!pendingMessageQueue) return;
438
+ var jobs = scheduler.listJobs();
439
+ for (var i = 0; i < jobs.length; i++) {
440
+ if (jobs[i].type !== type || jobs[i].projectId !== slug || !jobs[i].target || jobs[i].target.id !== session.cliSessionId) continue;
441
+ var actor = users.isMultiUser() ? users.findUserById(jobs[i].payload.actorId) : { id: "default" };
442
+ if (jobs[i].state === "queued" && !queueItemForJob(session, jobs[i], jobs[i].payload.actorId)) {
443
+ if (jobs[i].payload.queueAdmissionRequired !== true) pendingMessageQueue.upsertScheduled(session, {
444
+ type: "message", text: jobs[i].payload.text, clientMessageId: "scheduled-" + jobs[i].id,
445
+ schedule: { jobId: jobs[i].id, revision: jobs[i].payload.revision, notBefore: jobs[i].runAt, ready: false },
446
+ }, actor);
447
+ }
448
+ else if (["interrupted", "completed"].indexOf(jobs[i].state) >= 0) {
449
+ var existing = queueItemForJob(session, jobs[i], jobs[i].payload.actorId);
450
+ if (existing && existing.state === "pending" && jobs[i].runAt <= now() && authorizePending(existing, session)) {
451
+ var released = pendingMessageQueue.releaseScheduled(session, jobs[i].id, jobs[i].payload.revision);
452
+ if (released && typeof consumePendingMessage === "function") setImmediate(function () { consumePendingMessage(session); });
453
+ }
454
+ }
455
+ }
456
+ });
277
457
  }
278
458
 
279
459
  function deleted(localId, session) {
@@ -310,6 +490,9 @@ function attachProjectScheduledMessages(ctx) {
310
490
  notify: notify,
311
491
  shutdown: shutdown,
312
492
  handlerType: type,
493
+ authorizePending: authorizePending,
494
+ onQueueConsumed: onQueueConsumed,
495
+ onQueueCancelled: onQueueCancelled,
313
496
  };
314
497
  }
315
498
 
@@ -303,22 +303,42 @@ function attachUserMessage(ctx) {
303
303
  // --- Schedule message for after rate limit resets ---
304
304
  if (msg.type === "schedule_message") {
305
305
  var schedSession = getSessionForWs(ws);
306
- if (!schedSession || !msg.text || !msg.resetsAt) return true;
307
- scheduleMessage(schedSession, msg.text, msg.resetsAt, ws._clayUser || null, ws);
306
+ var transportAccountId = (ws && ws._clayUser && ws._clayUser.id) ? String(ws._clayUser.id) : "default";
307
+ if (!schedSession || msg.projectSlug !== slug || String(msg.sessionId) !== String(schedSession && schedSession.localId) ||
308
+ (msg.accountId || "default") !== transportAccountId) {
309
+ if (msg.requestId) sendTo(ws, { type: "schedule_message_result", requestId: msg.requestId,
310
+ projectSlug: msg.projectSlug || slug, sessionId: msg.sessionId == null ? null : msg.sessionId,
311
+ accountId: transportAccountId, ok: false, error: "The scheduling session or account is stale.", scheduledMessage: null });
312
+ return true;
313
+ }
314
+ scheduleMessage(schedSession, msg.text || "", msg.resetsAt, ws._clayUser || null, ws,
315
+ { images: msg.images, pastes: msg.pastes }, msg.requestId || null, transportAccountId);
308
316
  return true;
309
317
  }
310
318
 
311
319
  if (msg.type === "cancel_scheduled_message") {
312
320
  var cancelSession = getSessionForWs(ws);
313
321
  if (!cancelSession) return true;
314
- cancelScheduledMessage(cancelSession, ws._clayUser || null, ws, { jobId: msg.jobId, revision: msg.revision });
322
+ if (msg.projectSlug && msg.projectSlug !== slug) return true;
323
+ if (msg.sessionId != null && String(msg.sessionId) !== String(cancelSession.localId)) return true;
324
+ cancelScheduledMessage(cancelSession, ws._clayUser || null, ws, { jobId: msg.jobId, revision: msg.revision, queueRevision: msg.queueRevision, requestId: msg.requestId });
315
325
  return true;
316
326
  }
317
327
 
318
328
  if (msg.type === "send_scheduled_now") {
319
329
  var nowSession = getSessionForWs(ws);
320
- if (!nowSession || !nowSession.scheduledMessage) return true;
321
- sendScheduledMessageNow(nowSession, ws._clayUser || null, ws, { jobId: msg.jobId, revision: msg.revision });
330
+ var requestedContext = { projectSlug: slug, sessionId: nowSession ? nowSession.localId : msg.sessionId, revision: pendingMessageQueue ? pendingMessageQueue.getRevision() : 0, items: [], paused: false };
331
+ var controlResult;
332
+ if (!nowSession) controlResult = Object.assign({}, requestedContext, { ok: false, error: "The session is no longer available." });
333
+ else if (msg.projectSlug && msg.projectSlug !== slug) controlResult = Object.assign({}, requestedContext, { ok: false, error: "Project changed" });
334
+ else if (msg.sessionId != null && String(msg.sessionId) !== String(nowSession.localId)) controlResult = Object.assign({}, requestedContext, { ok: false, error: "Session changed" });
335
+ else if (!nowSession.scheduledMessage) controlResult = Object.assign({}, requestedContext, { ok: false, error: "The scheduled message is no longer available." });
336
+ else {
337
+ var sentNow = sendScheduledMessageNow(nowSession, ws._clayUser || null, ws, { jobId: msg.jobId, revision: msg.revision, queueRevision: msg.queueRevision, requestId: msg.requestId });
338
+ controlResult = pendingMessageQueue ? pendingMessageQueue.hydrate(nowSession, ws._clayUser || null) : { ok: sentNow === true, projectSlug: slug, sessionId: nowSession.localId, revision: 0, items: [], paused: false };
339
+ if (sentNow !== true) controlResult = Object.assign({}, controlResult, { ok: false, error: "Could not send the scheduled message." });
340
+ }
341
+ if (msg.requestId) sendTo(ws, { type: "pending_message_result", requestId: msg.requestId, result: controlResult });
322
342
  return true;
323
343
  }
324
344