clay-server 4.1.0-beta.4 → 4.1.0-beta.6
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/lib/autonomous-run-record.js +32 -0
- package/lib/autonomous-run-scheduler.js +92 -0
- package/lib/durable-scheduler-record.js +61 -0
- package/lib/durable-scheduler-store.js +185 -0
- package/lib/durable-scheduler.js +495 -0
- package/lib/loop-cron.js +47 -0
- package/lib/loop-durable-schedule.js +159 -0
- package/lib/loop-guidance.js +35 -0
- package/lib/loop-registry-occurrence.js +76 -0
- package/lib/project-autonomous-run.js +498 -0
- package/lib/project-connection.js +4 -1
- package/lib/project-full-access.js +86 -0
- package/lib/project-http.js +3 -1
- package/lib/project-loop-interview.js +163 -0
- package/lib/project-loop.js +150 -64
- package/lib/project-pair-autonomous-stop.js +16 -0
- package/lib/project-pair-owned-stop.js +18 -0
- package/lib/project-pair-task-control.js +14 -1
- package/lib/project-scheduled-messages.js +316 -0
- package/lib/project-scheduled-task-execution.js +236 -0
- package/lib/project-scheduled-tasks.js +483 -0
- package/lib/project-session-pair.js +16 -19
- package/lib/project-sessions.js +60 -28
- package/lib/project-user-message.js +24 -18
- package/lib/project.js +260 -52
- package/lib/public/app.js +60 -23
- package/lib/public/css/home-hub.css +15 -2
- package/lib/public/css/home-sidebar.css +50 -2
- package/lib/public/css/input.css +71 -0
- package/lib/public/css/mates.css +0 -1
- package/lib/public/css/pane.css +58 -7
- package/lib/public/css/rewind.css +11 -2
- package/lib/public/css/scheduled-tasks.css +123 -0
- package/lib/public/css/sticky-notes.css +24 -4
- package/lib/public/index.html +19 -134
- package/lib/public/modules/app-loop-ui.js +4 -8
- package/lib/public/modules/app-messages.js +22 -6
- package/lib/public/modules/app-projects.js +2 -0
- package/lib/public/modules/app-rate-limit.js +8 -5
- package/lib/public/modules/autonomous-run.js +285 -0
- package/lib/public/modules/home-sidebar.js +16 -8
- package/lib/public/modules/home-surface.js +5 -0
- package/lib/public/modules/input.js +5 -4
- package/lib/public/modules/loop-interview.js +151 -0
- package/lib/public/modules/project-logs.js +2 -2
- package/lib/public/modules/scheduled-message-state.js +46 -0
- package/lib/public/modules/scheduled-task-edit-state.js +7 -0
- package/lib/public/modules/scheduled-task-engine.js +98 -0
- package/lib/public/modules/scheduled-task-form.js +122 -0
- package/lib/public/modules/scheduled-task-recurrence.js +46 -0
- package/lib/public/modules/scheduled-task-schedule-rule.js +115 -0
- package/lib/public/modules/scheduled-tasks.js +468 -0
- package/lib/public/modules/scheduler-config.js +6 -52
- package/lib/public/modules/scheduler.js +9 -21
- package/lib/public/modules/sidebar.js +0 -11
- package/lib/public/modules/split-view.js +2 -0
- package/lib/public/modules/sticky-notes-browser.js +1 -1
- package/lib/public/modules/sticky-notes.js +117 -4
- package/lib/public/modules/tool-palette-order.js +2 -3
- package/lib/public/modules/tools.js +5 -2
- package/lib/public/style.css +1 -0
- package/lib/record-revision.js +11 -0
- package/lib/schedule-rule.js +151 -0
- package/lib/schedule-validation.js +124 -0
- package/lib/scheduled-run-state.js +87 -0
- package/lib/scheduled-task-records.js +127 -0
- package/lib/scheduler.js +179 -196
- package/lib/sdk-bridge.js +123 -11
- package/lib/sdk-message-processor.js +3 -0
- package/lib/server-durable-scheduler.js +26 -0
- package/lib/server.js +22 -0
- package/lib/session-pair-factory.js +42 -4
- package/lib/session-pair-query-start.js +22 -0
- package/lib/session-query-tool-bridge.js +52 -0
- package/lib/session-split-groups.js +35 -1
- package/lib/sessions.js +51 -3
- package/lib/users-home-surface-preferences.js +2 -1
- package/lib/users-scheduled-task-preferences.js +44 -0
- package/lib/users.js +6 -0
- package/lib/ws-schema.js +25 -0
- package/lib/yoke/adapters/antigravity.js +1 -0
- package/lib/yoke/adapters/claude-worker.js +7 -4
- package/lib/yoke/adapters/claude.js +31 -1
- package/lib/yoke/adapters/codex.js +3 -0
- package/lib/yoke/claude-user-input.js +2 -0
- package/lib/yoke/index.js +2 -0
- package/lib/yoke/mcp-bridge-server.js +7 -1
- package/lib/yoke/session-tool-transport.js +53 -0
- package/lib/yoke/user-input.js +21 -7
- package/package.json +1 -1
- package/lib/public/modules/app-loop-wizard.js +0 -439
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
var path = require("path");
|
|
3
|
+
var attachStore = require("./durable-scheduler-store").attachDurableSchedulerStore;
|
|
4
|
+
var validateSavedJobs = require("./durable-scheduler-record").validateSavedJobs;
|
|
5
|
+
|
|
6
|
+
function copy(value) {
|
|
7
|
+
if (value === undefined) return undefined;
|
|
8
|
+
return JSON.parse(JSON.stringify(value));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requiredString(value, name) {
|
|
12
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(name + " is required");
|
|
13
|
+
if (value.length > 256) throw new Error(name + " is too long");
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function safeValue(value, name, maxBytes) {
|
|
18
|
+
var cloned;
|
|
19
|
+
var serialized;
|
|
20
|
+
try {
|
|
21
|
+
cloned = copy(value);
|
|
22
|
+
serialized = JSON.stringify(cloned);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new Error(name + " must be JSON serializable");
|
|
25
|
+
}
|
|
26
|
+
if (Buffer.byteLength(serialized || "", "utf8") > maxBytes) throw new Error(name + " is too large");
|
|
27
|
+
return cloned;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function attachDurableScheduler(ctx) {
|
|
31
|
+
var namespace = requiredString(ctx.namespace, "namespace");
|
|
32
|
+
var now = ctx.now || Date.now;
|
|
33
|
+
var makeId = ctx.makeId || function () { return crypto.randomUUID(); };
|
|
34
|
+
var setTimer = ctx.setTimer || setTimeout;
|
|
35
|
+
var clearTimer = ctx.clearTimer || clearTimeout;
|
|
36
|
+
var maxTimerDelay = Math.min(Math.max(ctx.maxTimerDelay || 30000, 10), 2147483647);
|
|
37
|
+
var globalConcurrency = Math.max(1, ctx.globalConcurrency || 4);
|
|
38
|
+
var perOwnerConcurrency = Math.max(1, ctx.perOwnerConcurrency || 1);
|
|
39
|
+
var store = ctx.store || attachStore({
|
|
40
|
+
filePath: ctx.filePath || path.join(ctx.storageDir, "scheduler-" + namespace + ".json"),
|
|
41
|
+
namespace: namespace,
|
|
42
|
+
});
|
|
43
|
+
var handlers = Object.create(null);
|
|
44
|
+
var eligibility = Object.create(null);
|
|
45
|
+
var active = Object.create(null);
|
|
46
|
+
var timer = null;
|
|
47
|
+
var started = false;
|
|
48
|
+
var closed = false;
|
|
49
|
+
var unhealthyError = null;
|
|
50
|
+
var ownerCursor = 0;
|
|
51
|
+
var data;
|
|
52
|
+
|
|
53
|
+
function persist(nextData) {
|
|
54
|
+
if (unhealthyError) throw unhealthyError;
|
|
55
|
+
try {
|
|
56
|
+
store.save(nextData);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
unhealthyError = error;
|
|
59
|
+
stopTimer();
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
data = nextData;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function recover() {
|
|
66
|
+
try {
|
|
67
|
+
data = store.load();
|
|
68
|
+
validateSavedJobs(data.jobs);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
store.close();
|
|
71
|
+
var loadError = new Error("Scheduler store contains invalid durable data: " + error.message);
|
|
72
|
+
loadError.code = error.code || "SCHEDULER_STORE_CORRUPT";
|
|
73
|
+
loadError.cause = error;
|
|
74
|
+
throw loadError;
|
|
75
|
+
}
|
|
76
|
+
var changed = false;
|
|
77
|
+
var recoveredAt = now();
|
|
78
|
+
for (var i = 0; i < data.jobs.length; i++) {
|
|
79
|
+
var job = data.jobs[i];
|
|
80
|
+
if (job.state === "claimed" || job.state === "running") {
|
|
81
|
+
job.state = "interrupted";
|
|
82
|
+
job.updatedAt = recoveredAt;
|
|
83
|
+
job.execution = job.execution || {};
|
|
84
|
+
job.execution.interruptedAt = recoveredAt;
|
|
85
|
+
job.execution.interruptionReason = "server_restarted_after_claim";
|
|
86
|
+
changed = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (changed) persist(data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ensureReady() {
|
|
93
|
+
if (closed) throw new Error("Scheduler is shut down");
|
|
94
|
+
if (unhealthyError) throw unhealthyError;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findJob(id) {
|
|
98
|
+
for (var i = 0; i < data.jobs.length; i++) if (data.jobs[i].id === id) return data.jobs[i];
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findIdempotency(key, exceptId) {
|
|
103
|
+
for (var i = 0; i < data.jobs.length; i++) {
|
|
104
|
+
if (data.jobs[i].id !== exceptId && data.jobs[i].idempotencyKey === key) return data.jobs[i];
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateSpec(spec) {
|
|
110
|
+
var runAt = Number(spec.runAt);
|
|
111
|
+
if (!isFinite(runAt) || runAt < 0) throw new Error("runAt must be a non-negative timestamp");
|
|
112
|
+
var target = safeValue(spec.target, "target", 4096);
|
|
113
|
+
if (!target || typeof target !== "object" || Array.isArray(target)) throw new Error("target is required");
|
|
114
|
+
requiredString(target.kind, "target.kind");
|
|
115
|
+
requiredString(target.id, "target.id");
|
|
116
|
+
return {
|
|
117
|
+
type: requiredString(spec.type, "type"),
|
|
118
|
+
idempotencyKey: requiredString(spec.idempotencyKey, "idempotencyKey"),
|
|
119
|
+
ownerId: requiredString(spec.ownerId, "ownerId"),
|
|
120
|
+
projectId: requiredString(spec.projectId, "projectId"),
|
|
121
|
+
target: target,
|
|
122
|
+
payload: safeValue(spec.payload === undefined ? null : spec.payload, "payload", 256 * 1024),
|
|
123
|
+
runAt: runAt,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function enqueue(spec) {
|
|
128
|
+
ensureReady();
|
|
129
|
+
var valid = validateSpec(spec || {});
|
|
130
|
+
var existing = findIdempotency(valid.idempotencyKey);
|
|
131
|
+
if (existing) {
|
|
132
|
+
var same = existing.type === valid.type && existing.ownerId === valid.ownerId &&
|
|
133
|
+
existing.projectId === valid.projectId && existing.runAt === valid.runAt &&
|
|
134
|
+
JSON.stringify(existing.target) === JSON.stringify(valid.target) &&
|
|
135
|
+
JSON.stringify(existing.payload) === JSON.stringify(valid.payload);
|
|
136
|
+
if (!same) throw new Error("idempotencyKey was already used with different job data");
|
|
137
|
+
return copy(existing);
|
|
138
|
+
}
|
|
139
|
+
var createdAt = now();
|
|
140
|
+
var job = {
|
|
141
|
+
id: spec.id ? requiredString(spec.id, "id") : makeId(),
|
|
142
|
+
idempotencyKey: valid.idempotencyKey,
|
|
143
|
+
type: valid.type,
|
|
144
|
+
ownerId: valid.ownerId,
|
|
145
|
+
projectId: valid.projectId,
|
|
146
|
+
target: valid.target,
|
|
147
|
+
payload: valid.payload,
|
|
148
|
+
runAt: valid.runAt,
|
|
149
|
+
state: "queued",
|
|
150
|
+
createdAt: createdAt,
|
|
151
|
+
updatedAt: createdAt,
|
|
152
|
+
execution: null,
|
|
153
|
+
};
|
|
154
|
+
if (findJob(job.id)) throw new Error("job id already exists");
|
|
155
|
+
var next = copy(data);
|
|
156
|
+
next.jobs.push(job);
|
|
157
|
+
persist(next);
|
|
158
|
+
scheduleTimer();
|
|
159
|
+
return copy(job);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function cancel(id, reason) {
|
|
163
|
+
ensureReady();
|
|
164
|
+
var current = findJob(id);
|
|
165
|
+
if (!current) return null;
|
|
166
|
+
if (current.state !== "queued") throw new Error("Only queued jobs can be cancelled");
|
|
167
|
+
var next = copy(data);
|
|
168
|
+
var job = null;
|
|
169
|
+
for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
|
|
170
|
+
job.state = "cancelled";
|
|
171
|
+
job.updatedAt = now();
|
|
172
|
+
job.cancelledReason = typeof reason === "string" ? reason.slice(0, 1024) : null;
|
|
173
|
+
persist(next);
|
|
174
|
+
scheduleTimer();
|
|
175
|
+
return copy(job);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function replaceQueued(id, spec) {
|
|
179
|
+
ensureReady();
|
|
180
|
+
var current = findJob(id);
|
|
181
|
+
if (!current) return null;
|
|
182
|
+
if (current.state !== "queued") throw new Error("Only queued jobs can be replaced");
|
|
183
|
+
var merged = {
|
|
184
|
+
type: spec.type === undefined ? current.type : spec.type,
|
|
185
|
+
idempotencyKey: spec.idempotencyKey === undefined ? current.idempotencyKey : spec.idempotencyKey,
|
|
186
|
+
ownerId: spec.ownerId === undefined ? current.ownerId : spec.ownerId,
|
|
187
|
+
projectId: spec.projectId === undefined ? current.projectId : spec.projectId,
|
|
188
|
+
target: spec.target === undefined ? current.target : spec.target,
|
|
189
|
+
payload: spec.payload === undefined ? current.payload : spec.payload,
|
|
190
|
+
runAt: spec.runAt === undefined ? current.runAt : spec.runAt,
|
|
191
|
+
};
|
|
192
|
+
var valid = validateSpec(merged);
|
|
193
|
+
if (findIdempotency(valid.idempotencyKey, id)) throw new Error("idempotencyKey already exists");
|
|
194
|
+
var next = copy(data);
|
|
195
|
+
var job = null;
|
|
196
|
+
for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
|
|
197
|
+
job.type = valid.type;
|
|
198
|
+
job.idempotencyKey = valid.idempotencyKey;
|
|
199
|
+
job.ownerId = valid.ownerId;
|
|
200
|
+
job.projectId = valid.projectId;
|
|
201
|
+
job.target = valid.target;
|
|
202
|
+
job.payload = valid.payload;
|
|
203
|
+
job.runAt = valid.runAt;
|
|
204
|
+
job.updatedAt = now();
|
|
205
|
+
persist(next);
|
|
206
|
+
scheduleTimer();
|
|
207
|
+
return copy(job);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function activeOwnerCount(ownerId) {
|
|
211
|
+
var ids = Object.keys(active);
|
|
212
|
+
var count = 0;
|
|
213
|
+
for (var i = 0; i < ids.length; i++) if (active[ids[i]].ownerId === ownerId) count++;
|
|
214
|
+
return count;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isEligible(job) {
|
|
218
|
+
if (!eligibility[job.type]) return true;
|
|
219
|
+
try { return eligibility[job.type](copy(job)) === true; } catch (error) { return false; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function dueByFairOwner(timestamp) {
|
|
223
|
+
var byOwner = Object.create(null);
|
|
224
|
+
var owners = [];
|
|
225
|
+
for (var i = 0; i < data.jobs.length; i++) {
|
|
226
|
+
var job = data.jobs[i];
|
|
227
|
+
if (job.state !== "queued" || job.runAt > timestamp || !handlers[job.type]) continue;
|
|
228
|
+
if (!isEligible(job)) continue;
|
|
229
|
+
if (!byOwner[job.ownerId]) {
|
|
230
|
+
byOwner[job.ownerId] = [];
|
|
231
|
+
owners.push(job.ownerId);
|
|
232
|
+
}
|
|
233
|
+
byOwner[job.ownerId].push(job);
|
|
234
|
+
}
|
|
235
|
+
for (var ownerName in byOwner) {
|
|
236
|
+
byOwner[ownerName].sort(function (left, right) {
|
|
237
|
+
return left.runAt - right.runAt || left.createdAt - right.createdAt || left.id.localeCompare(right.id);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
owners.sort();
|
|
241
|
+
if (!owners.length) return [];
|
|
242
|
+
var offset = ownerCursor % owners.length;
|
|
243
|
+
var ordered = owners.slice(offset).concat(owners.slice(0, offset));
|
|
244
|
+
ownerCursor = (offset + 1) % owners.length;
|
|
245
|
+
var result = [];
|
|
246
|
+
var selectedByOwner = Object.create(null);
|
|
247
|
+
var available = globalConcurrency - Object.keys(active).length;
|
|
248
|
+
while (available > 0) {
|
|
249
|
+
var progressed = false;
|
|
250
|
+
for (var oi = 0; oi < ordered.length && available > 0; oi++) {
|
|
251
|
+
var owner = ordered[oi];
|
|
252
|
+
if (activeOwnerCount(owner) + (selectedByOwner[owner] || 0) >= perOwnerConcurrency) continue;
|
|
253
|
+
if (!byOwner[owner].length) continue;
|
|
254
|
+
result.push(byOwner[owner].shift());
|
|
255
|
+
selectedByOwner[owner] = (selectedByOwner[owner] || 0) + 1;
|
|
256
|
+
available--;
|
|
257
|
+
progressed = true;
|
|
258
|
+
}
|
|
259
|
+
if (!progressed) break;
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function updateExecution(id, claimId, mutate) {
|
|
265
|
+
ensureReady();
|
|
266
|
+
var current = findJob(id);
|
|
267
|
+
if (!current || !current.execution || current.execution.claimId !== claimId || current.state !== "running") {
|
|
268
|
+
throw new Error("Job execution is no longer active");
|
|
269
|
+
}
|
|
270
|
+
var next = copy(data);
|
|
271
|
+
var job = null;
|
|
272
|
+
for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
|
|
273
|
+
mutate(job);
|
|
274
|
+
job.updatedAt = now();
|
|
275
|
+
persist(next);
|
|
276
|
+
return copy(job.execution);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function settle(id, claimId, state, result) {
|
|
280
|
+
if (closed || unhealthyError) return;
|
|
281
|
+
var current = findJob(id);
|
|
282
|
+
if (!current || !current.execution || current.execution.claimId !== claimId || current.state !== "running") return;
|
|
283
|
+
var next = copy(data);
|
|
284
|
+
var job = null;
|
|
285
|
+
for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
|
|
286
|
+
job.state = state;
|
|
287
|
+
job.updatedAt = now();
|
|
288
|
+
if (state === "completed") {
|
|
289
|
+
job.execution.completedAt = job.updatedAt;
|
|
290
|
+
if (result !== undefined) {
|
|
291
|
+
try {
|
|
292
|
+
job.execution.result = safeValue(result, "handler result", 64 * 1024);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
job.state = "failed";
|
|
295
|
+
delete job.execution.completedAt;
|
|
296
|
+
job.execution.failedAt = job.updatedAt;
|
|
297
|
+
job.execution.error = error.message;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
job.execution.failedAt = job.updatedAt;
|
|
302
|
+
job.execution.error = String(result && result.message ? result.message : result).slice(0, 4096);
|
|
303
|
+
}
|
|
304
|
+
try { persist(next); } catch (error) {}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function requeueUnstarted(id, claimId) {
|
|
308
|
+
if (closed || unhealthyError) return;
|
|
309
|
+
var current = findJob(id);
|
|
310
|
+
if (!current || !current.execution || current.execution.claimId !== claimId || current.state !== "running") return;
|
|
311
|
+
var next = copy(data);
|
|
312
|
+
var job = null;
|
|
313
|
+
for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
|
|
314
|
+
job.state = "queued";
|
|
315
|
+
job.updatedAt = now();
|
|
316
|
+
job.execution = null;
|
|
317
|
+
persist(next);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function dispatch(job) {
|
|
321
|
+
if (active[job.id] || unhealthyError || closed) return;
|
|
322
|
+
var handler = handlers[job.type];
|
|
323
|
+
if (!handler) return;
|
|
324
|
+
var claimId = makeId();
|
|
325
|
+
var claimedAt = now();
|
|
326
|
+
var claimed = copy(data);
|
|
327
|
+
var claimedJob = null;
|
|
328
|
+
for (var i = 0; i < claimed.jobs.length; i++) if (claimed.jobs[i].id === job.id) claimedJob = claimed.jobs[i];
|
|
329
|
+
if (!claimedJob || claimedJob.state !== "queued") return;
|
|
330
|
+
claimedJob.state = "claimed";
|
|
331
|
+
claimedJob.updatedAt = claimedAt;
|
|
332
|
+
claimedJob.execution = { claimId: claimId, claimedAt: claimedAt, attempt: 1, metadata: null, receipt: null };
|
|
333
|
+
persist(claimed);
|
|
334
|
+
|
|
335
|
+
var running = copy(data);
|
|
336
|
+
var runningJob = null;
|
|
337
|
+
for (var ri = 0; ri < running.jobs.length; ri++) if (running.jobs[ri].id === job.id) runningJob = running.jobs[ri];
|
|
338
|
+
runningJob.state = "running";
|
|
339
|
+
runningJob.updatedAt = now();
|
|
340
|
+
runningJob.execution.startedAt = runningJob.updatedAt;
|
|
341
|
+
persist(running);
|
|
342
|
+
active[job.id] = { ownerId: job.ownerId, claimId: claimId };
|
|
343
|
+
|
|
344
|
+
var execution = {
|
|
345
|
+
claimId: claimId,
|
|
346
|
+
recordMetadata: function (metadata) {
|
|
347
|
+
return updateExecution(job.id, claimId, function (record) {
|
|
348
|
+
record.execution.metadata = safeValue(metadata, "execution metadata", 64 * 1024);
|
|
349
|
+
});
|
|
350
|
+
},
|
|
351
|
+
recordReceipt: function (receipt) {
|
|
352
|
+
return updateExecution(job.id, claimId, function (record) {
|
|
353
|
+
record.execution.receipt = safeValue(receipt, "execution receipt", 64 * 1024);
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
Promise.resolve().then(function () {
|
|
358
|
+
if (closed || unhealthyError || !active[job.id]) return undefined;
|
|
359
|
+
if (handlers[job.type] !== handler) {
|
|
360
|
+
requeueUnstarted(job.id, claimId);
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
if (!isEligible(runningJob)) {
|
|
364
|
+
requeueUnstarted(job.id, claimId);
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
return handler(copy(runningJob), execution);
|
|
368
|
+
}).then(function (result) {
|
|
369
|
+
settle(job.id, claimId, "completed", result);
|
|
370
|
+
}, function (error) {
|
|
371
|
+
settle(job.id, claimId, "failed", error);
|
|
372
|
+
}).finally(function () {
|
|
373
|
+
delete active[job.id];
|
|
374
|
+
scheduleTimer();
|
|
375
|
+
if (!closed && !unhealthyError) {
|
|
376
|
+
try { tick(); } catch (error) {}
|
|
377
|
+
}
|
|
378
|
+
}).catch(function () {});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function tick() {
|
|
382
|
+
ensureReady();
|
|
383
|
+
var due = dueByFairOwner(now());
|
|
384
|
+
for (var i = 0; i < due.length; i++) {
|
|
385
|
+
dispatch(due[i]);
|
|
386
|
+
}
|
|
387
|
+
scheduleTimer();
|
|
388
|
+
return due.length;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function stopTimer() {
|
|
392
|
+
if (timer !== null) {
|
|
393
|
+
clearTimer(timer);
|
|
394
|
+
timer = null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function scheduleTimer() {
|
|
399
|
+
stopTimer();
|
|
400
|
+
if (!started || closed || unhealthyError) return;
|
|
401
|
+
var timestamp = now();
|
|
402
|
+
var earliest = null;
|
|
403
|
+
var activeCount = Object.keys(active).length;
|
|
404
|
+
if (activeCount < globalConcurrency) {
|
|
405
|
+
for (var i = 0; i < data.jobs.length; i++) {
|
|
406
|
+
var job = data.jobs[i];
|
|
407
|
+
if (job.state !== "queued" || !handlers[job.type]) continue;
|
|
408
|
+
if (!isEligible(job)) continue;
|
|
409
|
+
if (activeOwnerCount(job.ownerId) >= perOwnerConcurrency) continue;
|
|
410
|
+
if (earliest === null || job.runAt < earliest) earliest = job.runAt;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
var delay = maxTimerDelay;
|
|
414
|
+
if (earliest !== null) delay = Math.min(maxTimerDelay, Math.max(0, earliest - timestamp));
|
|
415
|
+
timer = setTimer(function () {
|
|
416
|
+
timer = null;
|
|
417
|
+
try { tick(); } catch (error) {}
|
|
418
|
+
}, delay);
|
|
419
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function registerHandler(type, handler, options) {
|
|
423
|
+
ensureReady();
|
|
424
|
+
requiredString(type, "handler type");
|
|
425
|
+
if (typeof handler !== "function") throw new Error("handler must be a function");
|
|
426
|
+
if (options && options.canRun !== undefined && typeof options.canRun !== "function") throw new Error("canRun must be a function");
|
|
427
|
+
if (handlers[type]) throw new Error("handler already registered for " + type);
|
|
428
|
+
handlers[type] = handler;
|
|
429
|
+
if (options && options.canRun) eligibility[type] = options.canRun;
|
|
430
|
+
if (started) tick();
|
|
431
|
+
return function () {
|
|
432
|
+
if (handlers[type] !== handler) return;
|
|
433
|
+
delete handlers[type];
|
|
434
|
+
delete eligibility[type];
|
|
435
|
+
scheduleTimer();
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function start() {
|
|
440
|
+
ensureReady();
|
|
441
|
+
if (started) return false;
|
|
442
|
+
started = true;
|
|
443
|
+
tick();
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function shutdown() {
|
|
448
|
+
if (closed) return Promise.resolve();
|
|
449
|
+
closed = true;
|
|
450
|
+
started = false;
|
|
451
|
+
stopTimer();
|
|
452
|
+
var ids = Object.keys(active);
|
|
453
|
+
if (ids.length && !unhealthyError) {
|
|
454
|
+
var next = copy(data);
|
|
455
|
+
var stoppedAt = now();
|
|
456
|
+
for (var i = 0; i < next.jobs.length; i++) {
|
|
457
|
+
if (!active[next.jobs[i].id] || next.jobs[i].state !== "running") continue;
|
|
458
|
+
next.jobs[i].state = "interrupted";
|
|
459
|
+
next.jobs[i].updatedAt = stoppedAt;
|
|
460
|
+
next.jobs[i].execution.interruptedAt = stoppedAt;
|
|
461
|
+
next.jobs[i].execution.interruptionReason = "scheduler_shutdown";
|
|
462
|
+
}
|
|
463
|
+
try { store.save(next); data = next; } catch (error) { unhealthyError = error; }
|
|
464
|
+
}
|
|
465
|
+
store.close();
|
|
466
|
+
return Promise.resolve();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function getJob(id) { var job = findJob(id); return job ? copy(job) : null; }
|
|
470
|
+
function listJobs() { return copy(data.jobs); }
|
|
471
|
+
function getReceipt(id) { var job = findJob(id); return job && job.execution ? copy(job.execution.receipt) : null; }
|
|
472
|
+
function status() { return { started: started, closed: closed, healthy: !unhealthyError, error: unhealthyError ? unhealthyError.message : null, active: Object.keys(active).length }; }
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
recover();
|
|
476
|
+
} catch (error) {
|
|
477
|
+
store.close();
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
start: start,
|
|
482
|
+
shutdown: shutdown,
|
|
483
|
+
registerHandler: registerHandler,
|
|
484
|
+
enqueue: enqueue,
|
|
485
|
+
cancel: cancel,
|
|
486
|
+
replaceQueued: replaceQueued,
|
|
487
|
+
tick: tick,
|
|
488
|
+
getJob: getJob,
|
|
489
|
+
listJobs: listJobs,
|
|
490
|
+
getReceipt: getReceipt,
|
|
491
|
+
status: status,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
module.exports = { attachDurableScheduler: attachDurableScheduler };
|
package/lib/loop-cron.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
var validateCron = require("./schedule-validation").validateCron;
|
|
2
|
+
|
|
3
|
+
function parseField(field, min, max) {
|
|
4
|
+
var values = [];
|
|
5
|
+
var parts = field.split(",");
|
|
6
|
+
for (var i = 0; i < parts.length; i++) {
|
|
7
|
+
var part = parts[i].trim();
|
|
8
|
+
if (part.indexOf("/") !== -1) {
|
|
9
|
+
var slash = part.split("/");
|
|
10
|
+
var step = parseInt(slash[1], 10);
|
|
11
|
+
var range = slash[0] === "*" ? [min, max] : slash[0].split("-").map(function (value) { return parseInt(value, 10); });
|
|
12
|
+
if (range.length === 1) range.push(range[0]);
|
|
13
|
+
for (var stepped = range[0]; stepped <= range[1]; stepped += step) values.push(stepped);
|
|
14
|
+
} else if (part === "*") {
|
|
15
|
+
for (var any = min; any <= max; any++) values.push(any);
|
|
16
|
+
} else if (part.indexOf("-") !== -1) {
|
|
17
|
+
var bounds = part.split("-");
|
|
18
|
+
for (var ranged = parseInt(bounds[0], 10); ranged <= parseInt(bounds[1], 10); ranged++) values.push(ranged);
|
|
19
|
+
} else values.push(parseInt(part, 10));
|
|
20
|
+
}
|
|
21
|
+
return values;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parse(expr) {
|
|
25
|
+
if (!validateCron(expr).ok) return null;
|
|
26
|
+
var fields = expr.trim().split(/\s+/);
|
|
27
|
+
return { minutes: parseField(fields[0], 0, 59), hours: parseField(fields[1], 0, 23), daysOfMonth: parseField(fields[2], 1, 31), months: parseField(fields[3], 1, 12), daysOfWeek: parseField(fields[4], 0, 6) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matches(parsed, date) {
|
|
31
|
+
return parsed.minutes.indexOf(date.getMinutes()) !== -1 && parsed.hours.indexOf(date.getHours()) !== -1 &&
|
|
32
|
+
parsed.daysOfMonth.indexOf(date.getDate()) !== -1 && parsed.months.indexOf(date.getMonth() + 1) !== -1 && parsed.daysOfWeek.indexOf(date.getDay()) !== -1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nextRunTime(expr, after) {
|
|
36
|
+
var parsed = parse(expr);
|
|
37
|
+
if (!parsed) return null;
|
|
38
|
+
var date = new Date(after || Date.now());
|
|
39
|
+
date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + 1);
|
|
40
|
+
for (var i = 0; i < 366 * 24 * 60; i++) {
|
|
41
|
+
if (matches(parsed, date)) return date.getTime();
|
|
42
|
+
date.setMinutes(date.getMinutes() + 1);
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { nextRunTime: nextRunTime };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
|
|
3
|
+
function identity(prefix, projectId, scheduleIdentity, scheduleEpoch, occurrenceAt) {
|
|
4
|
+
var raw = projectId + "\n" + scheduleIdentity + "\n" + scheduleEpoch + "\n" + occurrenceAt;
|
|
5
|
+
return prefix + crypto.createHash("sha256").update(raw).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function attachLoopDurableSchedule(ctx) {
|
|
9
|
+
var scheduler = ctx.scheduler;
|
|
10
|
+
var registry = ctx.registry;
|
|
11
|
+
var type = "loop-occurrence:" + ctx.projectId;
|
|
12
|
+
var unregister = null;
|
|
13
|
+
var syncing = false;
|
|
14
|
+
|
|
15
|
+
function isMultiUser() { return typeof ctx.isMultiUser === "function" ? ctx.isMultiUser() : !!ctx.isMultiUser; }
|
|
16
|
+
|
|
17
|
+
function ownerFor(record) {
|
|
18
|
+
if (!isMultiUser()) return "local-user";
|
|
19
|
+
return typeof record.ownerId === "string" && record.ownerId ? record.ownerId : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function jobsFor(recordId) {
|
|
23
|
+
return scheduler.listJobs().filter(function (job) {
|
|
24
|
+
return job.type === type && job.projectId === ctx.projectId && job.target && job.target.id === recordId;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function queuedFor(recordId) {
|
|
29
|
+
var jobs = jobsFor(recordId);
|
|
30
|
+
for (var i = jobs.length - 1; i >= 0; i--) if (jobs[i].state === "queued") return jobs[i];
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function spec(record, ownerId) {
|
|
35
|
+
var dueAt = record.nextRunAt;
|
|
36
|
+
var occurrenceKey = identity("occurrence:", ctx.projectId, record.scheduleIdentity, record.scheduleEpoch, dueAt);
|
|
37
|
+
return {
|
|
38
|
+
idempotencyKey: "loop-" + occurrenceKey,
|
|
39
|
+
type: type,
|
|
40
|
+
ownerId: ownerId,
|
|
41
|
+
projectId: ctx.projectId,
|
|
42
|
+
target: { kind: "loop-record", id: record.id },
|
|
43
|
+
payload: { recordId: record.id, occurrenceAt: dueAt, occurrenceKey: occurrenceKey, scheduleIdentity: record.scheduleIdentity, scheduleEpoch: record.scheduleEpoch, recordRevision: Number(record.updatedAt || 0) },
|
|
44
|
+
runAt: dueAt,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function matchesOccurrence(job, record) {
|
|
49
|
+
return job && job.payload && job.payload.scheduleIdentity === record.scheduleIdentity &&
|
|
50
|
+
Number(job.payload.scheduleEpoch) === Number(record.scheduleEpoch) && job.payload.occurrenceAt === record.nextRunAt;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cancelQueued(recordId, reason) {
|
|
54
|
+
var jobs = jobsFor(recordId);
|
|
55
|
+
for (var i = 0; i < jobs.length; i++) {
|
|
56
|
+
if (jobs[i].state === "queued") scheduler.cancel(jobs[i].id, reason);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function syncRecord(record) {
|
|
61
|
+
var related = jobsFor(record.id);
|
|
62
|
+
var persistedDueOwned = related.some(function (job) {
|
|
63
|
+
return ["queued", "claimed", "running"].indexOf(job.state) !== -1 && matchesOccurrence(job, record);
|
|
64
|
+
});
|
|
65
|
+
var terminal = null;
|
|
66
|
+
for (var ti = related.length - 1; ti >= 0; ti--) {
|
|
67
|
+
if (["completed", "failed", "interrupted"].indexOf(related[ti].state) !== -1 && matchesOccurrence(related[ti], record)) { terminal = related[ti]; break; }
|
|
68
|
+
}
|
|
69
|
+
if (terminal && record.enabled && Number.isFinite(record.nextRunAt)) {
|
|
70
|
+
if (!registry.reconcileDurableOccurrence(record.id, record.nextRunAt)) throw new Error("The interrupted occurrence could not be reconciled.");
|
|
71
|
+
record = registry.getById(record.id); related = jobsFor(record.id); persistedDueOwned = false;
|
|
72
|
+
}
|
|
73
|
+
if (!persistedDueOwned) {
|
|
74
|
+
record = registry.normalizeDurableOccurrence(record.id, Date.now());
|
|
75
|
+
if (!record) throw new Error("The scheduled occurrence could not be normalized.");
|
|
76
|
+
related = jobsFor(record.id);
|
|
77
|
+
}
|
|
78
|
+
var queued = queuedFor(record.id);
|
|
79
|
+
var ownerId = ownerFor(record);
|
|
80
|
+
if (isMultiUser() && !ownerId) {
|
|
81
|
+
if (queued) scheduler.cancel(queued.id, "record_requires_owner");
|
|
82
|
+
registry.markNeedsOwner(record.id);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!record.enabled || !Number.isFinite(record.nextRunAt)) {
|
|
86
|
+
if (queued) scheduler.cancel(queued.id, "record_disabled_or_unscheduled");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
var wanted = spec(record, ownerId);
|
|
90
|
+
if (queued) {
|
|
91
|
+
if (matchesOccurrence(queued, record)) {
|
|
92
|
+
scheduler.replaceQueued(queued.id, wanted);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
scheduler.cancel(queued.id, "record_schedule_changed");
|
|
96
|
+
}
|
|
97
|
+
for (var i = related.length - 1; i >= 0; i--) {
|
|
98
|
+
if ((related[i].state === "claimed" || related[i].state === "running") && matchesOccurrence(related[i], record)) return;
|
|
99
|
+
if (related[i].state === "cancelled" && matchesOccurrence(related[i], record)) return;
|
|
100
|
+
}
|
|
101
|
+
scheduler.enqueue(wanted);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sync() {
|
|
105
|
+
if (syncing) return;
|
|
106
|
+
syncing = true;
|
|
107
|
+
try {
|
|
108
|
+
var records = registry.getAll();
|
|
109
|
+
var present = Object.create(null);
|
|
110
|
+
for (var i = 0; i < records.length; i++) {
|
|
111
|
+
present[records[i].id] = true;
|
|
112
|
+
syncRecord(records[i]);
|
|
113
|
+
}
|
|
114
|
+
var jobs = scheduler.listJobs();
|
|
115
|
+
for (var ji = 0; ji < jobs.length; ji++) {
|
|
116
|
+
if (jobs[ji].type === type && jobs[ji].state === "queued" && !present[jobs[ji].target.id]) {
|
|
117
|
+
scheduler.cancel(jobs[ji].id, "record_deleted");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} finally { syncing = false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function canRun(job) {
|
|
124
|
+
var record = registry.getById(job.target.id);
|
|
125
|
+
if (!record || !record.enabled || !matchesOccurrence(job, record)) return true;
|
|
126
|
+
if (ownerFor(record) !== job.ownerId) return true;
|
|
127
|
+
if (ctx.authorize && !ctx.authorize(record, job.ownerId)) return false;
|
|
128
|
+
return !ctx.canRun || ctx.canRun(record);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function dispatch(job, execution) {
|
|
132
|
+
var record = registry.getById(job.target.id);
|
|
133
|
+
if (!record) throw new Error("The scheduled record was deleted.");
|
|
134
|
+
if (!record.enabled || !matchesOccurrence(job, record)) throw new Error("The scheduled occurrence is stale.");
|
|
135
|
+
if (ownerFor(record) !== job.ownerId) throw new Error("The scheduled record owner changed.");
|
|
136
|
+
if (ctx.authorize && !ctx.authorize(record, job.ownerId)) throw new Error("The scheduled record owner is no longer authorized.");
|
|
137
|
+
execution.recordMetadata({ projectId: ctx.projectId, recordId: record.id, occurrenceAt: job.payload.occurrenceAt, recordRevision: Number(record.updatedAt || 0) });
|
|
138
|
+
var prepared = registry.prepareDurableOccurrence(record.id, job.payload.occurrenceAt);
|
|
139
|
+
if (!prepared || prepared.ok === false) throw new Error(prepared && prepared.error || "The scheduled occurrence could not be reserved.");
|
|
140
|
+
execution.recordReceipt({ state: "dispatching", projectId: ctx.projectId, recordId: record.id, occurrenceAt: job.payload.occurrenceAt, recordedAt: Date.now() });
|
|
141
|
+
var triggerError = null;
|
|
142
|
+
try { registry.triggerDurableOccurrence(record.id, prepared.value); }
|
|
143
|
+
catch (error) { triggerError = error; }
|
|
144
|
+
if (!registry.completeDurableOccurrence(record.id, job.payload.occurrenceAt)) throw new Error("The next scheduled occurrence could not be stored after dispatch.");
|
|
145
|
+
if (triggerError) throw triggerError;
|
|
146
|
+
return { dispatched: true, recordId: record.id, occurrenceAt: job.payload.occurrenceAt };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
unregister = scheduler.registerHandler(type, dispatch, { canRun: canRun });
|
|
150
|
+
sync();
|
|
151
|
+
return {
|
|
152
|
+
sync: sync,
|
|
153
|
+
wake: function () { try { scheduler.tick(); } catch (error) {} },
|
|
154
|
+
shutdown: function () { if (unregister) { unregister(); unregister = null; } },
|
|
155
|
+
type: type,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { attachLoopDurableSchedule: attachLoopDurableSchedule };
|