@sigil0/looking-glass 0.1.0
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/LICENSE +21 -0
- package/README.md +572 -0
- package/dist/agents/coordinator.d.ts +21 -0
- package/dist/agents/coordinator.d.ts.map +1 -0
- package/dist/agents/coordinator.js +171 -0
- package/dist/agents/coordinator.js.map +1 -0
- package/dist/app.d.ts +44 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +215 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +419 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +132 -0
- package/dist/config.js.map +1 -0
- package/dist/engine/context.d.ts +6 -0
- package/dist/engine/context.d.ts.map +1 -0
- package/dist/engine/context.js +65 -0
- package/dist/engine/context.js.map +1 -0
- package/dist/engine/engine.d.ts +74 -0
- package/dist/engine/engine.d.ts.map +1 -0
- package/dist/engine/engine.js +573 -0
- package/dist/engine/engine.js.map +1 -0
- package/dist/engine/types.d.ts +35 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/types.js +2 -0
- package/dist/engine/types.js.map +1 -0
- package/dist/instructions.d.ts +8 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +60 -0
- package/dist/instructions.js.map +1 -0
- package/dist/model/codex-lb.d.ts +141 -0
- package/dist/model/codex-lb.d.ts.map +1 -0
- package/dist/model/codex-lb.js +471 -0
- package/dist/model/codex-lb.js.map +1 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +41 -0
- package/dist/paths.js.map +1 -0
- package/dist/scheduler/daemon.d.ts +40 -0
- package/dist/scheduler/daemon.d.ts.map +1 -0
- package/dist/scheduler/daemon.js +189 -0
- package/dist/scheduler/daemon.js.map +1 -0
- package/dist/scheduler/index.d.ts +8 -0
- package/dist/scheduler/index.d.ts.map +1 -0
- package/dist/scheduler/index.js +6 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/runner.d.ts +18 -0
- package/dist/scheduler/runner.d.ts.map +1 -0
- package/dist/scheduler/runner.js +173 -0
- package/dist/scheduler/runner.js.map +1 -0
- package/dist/scheduler/schedule.d.ts +7 -0
- package/dist/scheduler/schedule.d.ts.map +1 -0
- package/dist/scheduler/schedule.js +94 -0
- package/dist/scheduler/schedule.js.map +1 -0
- package/dist/scheduler/store.d.ts +55 -0
- package/dist/scheduler/store.d.ts.map +1 -0
- package/dist/scheduler/store.js +1203 -0
- package/dist/scheduler/store.js.map +1 -0
- package/dist/scheduler/systemd.d.ts +7 -0
- package/dist/scheduler/systemd.d.ts.map +1 -0
- package/dist/scheduler/systemd.js +97 -0
- package/dist/scheduler/systemd.js.map +1 -0
- package/dist/scheduler/types.d.ts +145 -0
- package/dist/scheduler/types.d.ts.map +1 -0
- package/dist/scheduler/types.js +7 -0
- package/dist/scheduler/types.js.map +1 -0
- package/dist/security.d.ts +6 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +27 -0
- package/dist/security.js.map +1 -0
- package/dist/storage/artifact-store.d.ts +19 -0
- package/dist/storage/artifact-store.d.ts.map +1 -0
- package/dist/storage/artifact-store.js +47 -0
- package/dist/storage/artifact-store.js.map +1 -0
- package/dist/storage/database.d.ts +5 -0
- package/dist/storage/database.d.ts.map +1 -0
- package/dist/storage/database.js +395 -0
- package/dist/storage/database.js.map +1 -0
- package/dist/storage/session-store.d.ts +114 -0
- package/dist/storage/session-store.d.ts.map +1 -0
- package/dist/storage/session-store.js +731 -0
- package/dist/storage/session-store.js.map +1 -0
- package/dist/tools/agents.d.ts +14 -0
- package/dist/tools/agents.d.ts.map +1 -0
- package/dist/tools/agents.js +35 -0
- package/dist/tools/agents.js.map +1 -0
- package/dist/tools/apply-patch.d.ts +7 -0
- package/dist/tools/apply-patch.d.ts.map +1 -0
- package/dist/tools/apply-patch.js +429 -0
- package/dist/tools/apply-patch.js.map +1 -0
- package/dist/tools/ask.d.ts +8 -0
- package/dist/tools/ask.d.ts.map +1 -0
- package/dist/tools/ask.js +27 -0
- package/dist/tools/ask.js.map +1 -0
- package/dist/tools/bash.d.ts +9 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +98 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +34 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/paths.d.ts +3 -0
- package/dist/tools/paths.d.ts.map +1 -0
- package/dist/tools/paths.js +34 -0
- package/dist/tools/paths.js.map +1 -0
- package/dist/tools/process.d.ts +21 -0
- package/dist/tools/process.d.ts.map +1 -0
- package/dist/tools/process.js +104 -0
- package/dist/tools/process.js.map +1 -0
- package/dist/tools/read.d.ts +9 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +95 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/registry.d.ts +19 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +188 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/safety.d.ts +9 -0
- package/dist/tools/safety.d.ts.map +1 -0
- package/dist/tools/safety.js +199 -0
- package/dist/tools/safety.js.map +1 -0
- package/dist/tools/schedule.d.ts +4 -0
- package/dist/tools/schedule.d.ts.map +1 -0
- package/dist/tools/schedule.js +191 -0
- package/dist/tools/schedule.js.map +1 -0
- package/dist/tools/search.d.ts +16 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +75 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/types.d.ts +54 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +10 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/stdio.d.ts +5 -0
- package/dist/ui/stdio.d.ts.map +1 -0
- package/dist/ui/stdio.js +65 -0
- package/dist/ui/stdio.js.map +1 -0
- package/dist/ui/tui.d.ts +93 -0
- package/dist/ui/tui.d.ts.map +1 -0
- package/dist/ui/tui.js +2246 -0
- package/dist/ui/tui.js.map +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { initialDue, nextCronDue, rejectNul } from "./schedule.js";
|
|
5
|
+
import { sessionPromptReservation } from "./types.js";
|
|
6
|
+
const MAX_DURATION_MS = 2_147_483_647;
|
|
7
|
+
const MAX_OUTPUT_BYTES = 100 * 1024 * 1024;
|
|
8
|
+
const SESSION_PROMPT_COMMAND_SENTINEL = ":";
|
|
9
|
+
function finiteInteger(name, value, minimum, maximum = Number.MAX_SAFE_INTEGER) {
|
|
10
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
11
|
+
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function validateNow(now) {
|
|
15
|
+
finiteInteger("now", now, 0);
|
|
16
|
+
}
|
|
17
|
+
function jobFromRow(row) {
|
|
18
|
+
const env = row.env_json ? JSON.parse(row.env_json) : {};
|
|
19
|
+
const sessionPrompt = row.kind === "command" && row.session_id !== null && row.prompt !== null;
|
|
20
|
+
return {
|
|
21
|
+
id: row.id,
|
|
22
|
+
kind: sessionPrompt ? "session_prompt" : row.kind,
|
|
23
|
+
scheduleKind: row.schedule_kind,
|
|
24
|
+
schedule: row.schedule,
|
|
25
|
+
timezone: row.timezone,
|
|
26
|
+
message: row.message,
|
|
27
|
+
command: row.command_text,
|
|
28
|
+
sessionId: row.session_id,
|
|
29
|
+
prompt: row.prompt,
|
|
30
|
+
cwd: row.cwd,
|
|
31
|
+
env,
|
|
32
|
+
startGraceMs: row.start_grace_ms,
|
|
33
|
+
timeoutMs: row.timeout_ms,
|
|
34
|
+
outputBytes: row.output_bytes,
|
|
35
|
+
enabled: row.enabled === 1,
|
|
36
|
+
blockedReason: row.blocked_reason,
|
|
37
|
+
suspendedBySession: row.suspended_by_session === 1,
|
|
38
|
+
nextDue: row.next_due,
|
|
39
|
+
createdAt: row.created_at,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function occurrenceFromRow(row) {
|
|
43
|
+
return {
|
|
44
|
+
id: row.id,
|
|
45
|
+
jobId: row.job_id,
|
|
46
|
+
scheduledAt: row.scheduled_at,
|
|
47
|
+
state: row.state,
|
|
48
|
+
claimToken: row.claim_token,
|
|
49
|
+
claimOwner: row.claim_owner,
|
|
50
|
+
claimBootId: row.claim_boot_id,
|
|
51
|
+
claimLeaseExpiresAt: row.claim_lease_expires_at,
|
|
52
|
+
createdAt: row.created_at,
|
|
53
|
+
claimedAt: row.claimed_at,
|
|
54
|
+
startedAt: row.started_at,
|
|
55
|
+
finishedAt: row.finished_at,
|
|
56
|
+
exitCode: row.exit_code,
|
|
57
|
+
signal: row.signal,
|
|
58
|
+
reason: row.reason,
|
|
59
|
+
stdout: row.stdout ?? Buffer.alloc(0),
|
|
60
|
+
stderr: row.stderr ?? Buffer.alloc(0),
|
|
61
|
+
stdoutBytes: row.stdout_bytes,
|
|
62
|
+
stderrBytes: row.stderr_bytes,
|
|
63
|
+
stdoutTruncated: row.stdout_truncated === 1,
|
|
64
|
+
stderrTruncated: row.stderr_truncated === 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function leaseFromRow(row) {
|
|
68
|
+
return {
|
|
69
|
+
owner: row.owner,
|
|
70
|
+
bootId: row.boot_id,
|
|
71
|
+
acquiredAt: row.acquired_at,
|
|
72
|
+
renewedAt: row.renewed_at,
|
|
73
|
+
expiresAt: row.expires_at,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function validateIdentity(value, field) {
|
|
77
|
+
rejectNul(value, field);
|
|
78
|
+
if (value.length === 0)
|
|
79
|
+
throw new Error(`${field} must not be empty`);
|
|
80
|
+
}
|
|
81
|
+
function validateEnvironment(env) {
|
|
82
|
+
for (const [key, value] of Object.entries(env)) {
|
|
83
|
+
validateIdentity(key, "environment variable name");
|
|
84
|
+
if (key.includes("="))
|
|
85
|
+
throw new Error("Environment variable names must not contain '='");
|
|
86
|
+
if (typeof value !== "string")
|
|
87
|
+
throw new Error(`Environment variable ${key} must be a string`);
|
|
88
|
+
rejectNul(value, `environment variable ${key}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function resultInboxMessage(job, state, reason) {
|
|
92
|
+
const subject = job.kind === "session_prompt" ? "Session turn" : "Command";
|
|
93
|
+
return reason ? `${subject} ${state}: ${reason}` : `${subject} ${state}`;
|
|
94
|
+
}
|
|
95
|
+
function boundedUtf8(value, maximumBytes) {
|
|
96
|
+
const full = Buffer.from(value, "utf8");
|
|
97
|
+
if (full.byteLength <= maximumBytes)
|
|
98
|
+
return { buffer: full, bytes: full.byteLength, truncated: false };
|
|
99
|
+
let end = maximumBytes;
|
|
100
|
+
if (end > 0) {
|
|
101
|
+
let sequenceStart = end - 1;
|
|
102
|
+
while (sequenceStart > 0 && (full[sequenceStart] & 0xc0) === 0x80)
|
|
103
|
+
sequenceStart -= 1;
|
|
104
|
+
const lead = full[sequenceStart];
|
|
105
|
+
const sequenceBytes = lead < 0x80 ? 1 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : 4;
|
|
106
|
+
if (sequenceStart + sequenceBytes > end)
|
|
107
|
+
end = sequenceStart;
|
|
108
|
+
}
|
|
109
|
+
return { buffer: full.subarray(0, end), bytes: full.byteLength, truncated: true };
|
|
110
|
+
}
|
|
111
|
+
export class SchedulerStore {
|
|
112
|
+
db;
|
|
113
|
+
constructor(db) {
|
|
114
|
+
this.db = db;
|
|
115
|
+
}
|
|
116
|
+
createReminder(input, now = Date.now()) {
|
|
117
|
+
validateNow(now);
|
|
118
|
+
rejectNul(input.message, "message");
|
|
119
|
+
if (input.message.length === 0)
|
|
120
|
+
throw new Error("message must not be empty");
|
|
121
|
+
const nextDue = initialDue(input.scheduleKind, input.schedule, input.timezone, now);
|
|
122
|
+
const id = randomUUID();
|
|
123
|
+
this.db.prepare(`
|
|
124
|
+
INSERT INTO scheduler_jobs(
|
|
125
|
+
id, kind, schedule_kind, schedule, timezone, message, enabled, next_due, created_at
|
|
126
|
+
) VALUES (?, 'reminder', ?, ?, ?, ?, 1, ?, ?)
|
|
127
|
+
`).run(id, input.scheduleKind, input.schedule, input.timezone, input.message, nextDue, now);
|
|
128
|
+
return this.requireJob(id);
|
|
129
|
+
}
|
|
130
|
+
createCommand(input, now = Date.now()) {
|
|
131
|
+
validateNow(now);
|
|
132
|
+
rejectNul(input.command, "command");
|
|
133
|
+
rejectNul(input.cwd, "cwd");
|
|
134
|
+
if (input.command.length === 0)
|
|
135
|
+
throw new Error("command must not be empty");
|
|
136
|
+
if (!isAbsolute(input.cwd))
|
|
137
|
+
throw new Error("Command cwd must be absolute");
|
|
138
|
+
let cwdIsDirectory = false;
|
|
139
|
+
try {
|
|
140
|
+
cwdIsDirectory = statSync(input.cwd).isDirectory();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new Error(`Command cwd does not exist: ${input.cwd}`);
|
|
144
|
+
}
|
|
145
|
+
if (!cwdIsDirectory)
|
|
146
|
+
throw new Error(`Command cwd is not a directory: ${input.cwd}`);
|
|
147
|
+
finiteInteger("startGraceMs", input.startGraceMs, 0, MAX_DURATION_MS);
|
|
148
|
+
finiteInteger("timeoutMs", input.timeoutMs, 1, MAX_DURATION_MS);
|
|
149
|
+
finiteInteger("outputBytes", input.outputBytes, 0, MAX_OUTPUT_BYTES);
|
|
150
|
+
if (input.env !== undefined && (input.env === null || typeof input.env !== "object" || Array.isArray(input.env))) {
|
|
151
|
+
throw new Error("env must be an object of string overrides");
|
|
152
|
+
}
|
|
153
|
+
const env = input.env ?? {};
|
|
154
|
+
validateEnvironment(env);
|
|
155
|
+
const nextDue = initialDue(input.scheduleKind, input.schedule, input.timezone, now);
|
|
156
|
+
const id = randomUUID();
|
|
157
|
+
this.db.prepare(`
|
|
158
|
+
INSERT INTO scheduler_jobs(
|
|
159
|
+
id, kind, schedule_kind, schedule, timezone, command_text, cwd, env_json,
|
|
160
|
+
start_grace_ms, timeout_ms, output_bytes, enabled, next_due, created_at
|
|
161
|
+
) VALUES (?, 'command', ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
162
|
+
`).run(id, input.scheduleKind, input.schedule, input.timezone, input.command, input.cwd, JSON.stringify(env), input.startGraceMs, input.timeoutMs, input.outputBytes, nextDue, now);
|
|
163
|
+
return this.requireJob(id);
|
|
164
|
+
}
|
|
165
|
+
createSessionPrompt(input, now = Date.now()) {
|
|
166
|
+
validateNow(now);
|
|
167
|
+
validateIdentity(input.sessionId, "sessionId");
|
|
168
|
+
rejectNul(input.prompt, "prompt");
|
|
169
|
+
if (input.prompt.trim().length === 0)
|
|
170
|
+
throw new Error("prompt must not be empty");
|
|
171
|
+
finiteInteger("startGraceMs", input.startGraceMs, 0, MAX_DURATION_MS);
|
|
172
|
+
finiteInteger("outputBytes", input.outputBytes, 0, MAX_OUTPUT_BYTES);
|
|
173
|
+
const nextDue = initialDue(input.scheduleKind, input.schedule, input.timezone, now);
|
|
174
|
+
const id = randomUUID();
|
|
175
|
+
const create = this.db.transaction(() => {
|
|
176
|
+
const session = this.db.prepare(`
|
|
177
|
+
SELECT workspace, persistent FROM sessions WHERE id = ?
|
|
178
|
+
`).get(input.sessionId);
|
|
179
|
+
if (!session)
|
|
180
|
+
throw new Error(`Session not found: ${input.sessionId}`);
|
|
181
|
+
if (session.persistent !== 1)
|
|
182
|
+
throw new Error("Session must be persistent before scheduling prompts");
|
|
183
|
+
this.db.prepare(`
|
|
184
|
+
INSERT INTO scheduler_jobs(
|
|
185
|
+
id, kind, schedule_kind, schedule, timezone, command_text, cwd, env_json,
|
|
186
|
+
start_grace_ms, timeout_ms, output_bytes, enabled, next_due, created_at,
|
|
187
|
+
session_id, prompt
|
|
188
|
+
) VALUES (?, 'command', ?, ?, ?, ?, ?, '{}', ?, ?, ?, 1, ?, ?, ?, ?)
|
|
189
|
+
`).run(id, input.scheduleKind, input.schedule, input.timezone, SESSION_PROMPT_COMMAND_SENTINEL, session.workspace, input.startGraceMs, MAX_DURATION_MS, input.outputBytes, nextDue, now, input.sessionId, input.prompt);
|
|
190
|
+
return this.requireJob(id);
|
|
191
|
+
});
|
|
192
|
+
return create.immediate();
|
|
193
|
+
}
|
|
194
|
+
listJobs(includeDeleted = false) {
|
|
195
|
+
const rows = (includeDeleted
|
|
196
|
+
? this.db.prepare("SELECT * FROM scheduler_jobs ORDER BY created_at DESC, id").all()
|
|
197
|
+
: this.db.prepare(`
|
|
198
|
+
SELECT * FROM scheduler_jobs
|
|
199
|
+
WHERE blocked_reason IS NULL OR blocked_reason <> 'deleted'
|
|
200
|
+
ORDER BY created_at DESC, id
|
|
201
|
+
`).all());
|
|
202
|
+
return rows.map(jobFromRow);
|
|
203
|
+
}
|
|
204
|
+
listJobsForSession(sessionId) {
|
|
205
|
+
validateIdentity(sessionId, "sessionId");
|
|
206
|
+
const rows = this.db.prepare(`
|
|
207
|
+
SELECT * FROM scheduler_jobs
|
|
208
|
+
WHERE kind = 'command' AND session_id = ? AND prompt IS NOT NULL
|
|
209
|
+
AND (blocked_reason IS NULL OR blocked_reason <> 'deleted')
|
|
210
|
+
ORDER BY created_at DESC, id
|
|
211
|
+
`).all(sessionId);
|
|
212
|
+
return rows.map(jobFromRow);
|
|
213
|
+
}
|
|
214
|
+
setSessionJobsEnabled(sessionId, enabled, now = Date.now()) {
|
|
215
|
+
validateIdentity(sessionId, "sessionId");
|
|
216
|
+
if (typeof enabled !== "boolean")
|
|
217
|
+
throw new Error("enabled must be a boolean");
|
|
218
|
+
validateNow(now);
|
|
219
|
+
const update = this.db.transaction(() => {
|
|
220
|
+
if (enabled && !this.isPersistentSession(sessionId)) {
|
|
221
|
+
throw new Error("Session must be persistent before enabling scheduled prompts");
|
|
222
|
+
}
|
|
223
|
+
const rows = this.db.prepare(`
|
|
224
|
+
SELECT * FROM scheduler_jobs
|
|
225
|
+
WHERE kind = 'command' AND session_id = ? AND prompt IS NOT NULL
|
|
226
|
+
AND (blocked_reason IS NULL OR blocked_reason <> 'deleted')
|
|
227
|
+
ORDER BY created_at DESC, id
|
|
228
|
+
`).all(sessionId);
|
|
229
|
+
if (!enabled) {
|
|
230
|
+
for (const row of rows) {
|
|
231
|
+
if (row.enabled === 1) {
|
|
232
|
+
this.db.prepare("UPDATE scheduler_jobs SET enabled = 0, suspended_by_session = 1 WHERE id = ?").run(row.id);
|
|
233
|
+
this.cancelUnstarted(row.id, "Session prompt scheduling disabled", now);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
for (const row of rows) {
|
|
239
|
+
const job = jobFromRow(row);
|
|
240
|
+
if (!job.suspendedBySession)
|
|
241
|
+
continue;
|
|
242
|
+
if (job.blockedReason !== null)
|
|
243
|
+
continue;
|
|
244
|
+
if (job.scheduleKind === "once") {
|
|
245
|
+
if (job.nextDue !== null) {
|
|
246
|
+
this.db.prepare("UPDATE scheduler_jobs SET enabled = 1, suspended_by_session = 0 WHERE id = ?").run(job.id);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
this.db.prepare("UPDATE scheduler_jobs SET suspended_by_session = 0 WHERE id = ?").run(job.id);
|
|
250
|
+
}
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const nextDue = job.nextDue === null || job.nextDue <= now
|
|
254
|
+
? nextCronDue(job.schedule, job.timezone, now)
|
|
255
|
+
: job.nextDue;
|
|
256
|
+
this.db.prepare("UPDATE scheduler_jobs SET enabled = 1, suspended_by_session = 0, next_due = ? WHERE id = ?")
|
|
257
|
+
.run(nextDue, job.id);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return this.listJobsForSession(sessionId);
|
|
261
|
+
});
|
|
262
|
+
return update.immediate();
|
|
263
|
+
}
|
|
264
|
+
getJob(id) {
|
|
265
|
+
const row = this.db.prepare("SELECT * FROM scheduler_jobs WHERE id = ?").get(id);
|
|
266
|
+
return row ? jobFromRow(row) : null;
|
|
267
|
+
}
|
|
268
|
+
pauseJob(id, now = Date.now()) {
|
|
269
|
+
validateNow(now);
|
|
270
|
+
const pause = this.db.transaction(() => {
|
|
271
|
+
this.requireJob(id);
|
|
272
|
+
this.db.prepare("UPDATE scheduler_jobs SET enabled = 0, suspended_by_session = 0 WHERE id = ?").run(id);
|
|
273
|
+
this.cancelUnstarted(id, "Job paused", now);
|
|
274
|
+
return this.requireJob(id);
|
|
275
|
+
});
|
|
276
|
+
return pause.immediate();
|
|
277
|
+
}
|
|
278
|
+
resumeJob(id) {
|
|
279
|
+
const resume = this.db.transaction(() => {
|
|
280
|
+
const job = this.requireJob(id);
|
|
281
|
+
if (job.blockedReason)
|
|
282
|
+
throw new Error(`Job is blocked: ${job.blockedReason}`);
|
|
283
|
+
if (job.nextDue === null)
|
|
284
|
+
throw new Error("Job has no future occurrence");
|
|
285
|
+
if (job.kind === "session_prompt" && !this.isPersistentSession(job.sessionId)) {
|
|
286
|
+
throw new Error("Session must be persistent before resuming scheduled prompts");
|
|
287
|
+
}
|
|
288
|
+
this.db.prepare("UPDATE scheduler_jobs SET enabled = 1, suspended_by_session = 0 WHERE id = ?").run(id);
|
|
289
|
+
return this.requireJob(id);
|
|
290
|
+
});
|
|
291
|
+
return resume.immediate();
|
|
292
|
+
}
|
|
293
|
+
acknowledgeUnknown(id, now = Date.now()) {
|
|
294
|
+
validateNow(now);
|
|
295
|
+
const acknowledge = this.db.transaction(() => {
|
|
296
|
+
const job = this.requireJob(id);
|
|
297
|
+
if (!job.blockedReason)
|
|
298
|
+
return job;
|
|
299
|
+
if (!/unknown outcome/i.test(job.blockedReason)) {
|
|
300
|
+
throw new Error(`Job is blocked for another reason: ${job.blockedReason}`);
|
|
301
|
+
}
|
|
302
|
+
let nextDue = job.nextDue;
|
|
303
|
+
if (job.scheduleKind === "cron" && (nextDue === null || nextDue <= now)) {
|
|
304
|
+
nextDue = nextCronDue(job.schedule, job.timezone, now);
|
|
305
|
+
}
|
|
306
|
+
const canEnable = job.scheduleKind === "cron"
|
|
307
|
+
&& (job.kind !== "session_prompt" || this.isPersistentSession(job.sessionId));
|
|
308
|
+
this.db.prepare(`
|
|
309
|
+
UPDATE scheduler_jobs
|
|
310
|
+
SET blocked_reason = NULL, enabled = ?, suspended_by_session = ?, next_due = ?
|
|
311
|
+
WHERE id = ?
|
|
312
|
+
`).run(canEnable ? 1 : 0, job.kind === "session_prompt" && !canEnable ? 1 : 0, nextDue, id);
|
|
313
|
+
return this.requireJob(id);
|
|
314
|
+
});
|
|
315
|
+
return acknowledge.immediate();
|
|
316
|
+
}
|
|
317
|
+
deleteJob(id, now = Date.now()) {
|
|
318
|
+
validateNow(now);
|
|
319
|
+
const remove = this.db.transaction(() => {
|
|
320
|
+
this.requireJob(id);
|
|
321
|
+
this.db.prepare(`
|
|
322
|
+
UPDATE scheduler_jobs
|
|
323
|
+
SET enabled = 0, suspended_by_session = 0, blocked_reason = 'deleted'
|
|
324
|
+
WHERE id = ?
|
|
325
|
+
`).run(id);
|
|
326
|
+
this.cancelUnstarted(id, "Job deleted", now);
|
|
327
|
+
return this.requireJob(id);
|
|
328
|
+
});
|
|
329
|
+
return remove.immediate();
|
|
330
|
+
}
|
|
331
|
+
listInbox(options = {}) {
|
|
332
|
+
const limit = options.limit ?? 100;
|
|
333
|
+
finiteInteger("limit", limit, 1, 10_000);
|
|
334
|
+
const rows = (options.unackedOnly ?? true)
|
|
335
|
+
? this.db.prepare(`
|
|
336
|
+
SELECT * FROM scheduler_inbox
|
|
337
|
+
WHERE acknowledged_at IS NULL
|
|
338
|
+
ORDER BY created_at DESC, id DESC LIMIT ?
|
|
339
|
+
`).all(limit)
|
|
340
|
+
: this.db.prepare(`
|
|
341
|
+
SELECT * FROM scheduler_inbox
|
|
342
|
+
ORDER BY created_at DESC, id DESC LIMIT ?
|
|
343
|
+
`).all(limit);
|
|
344
|
+
return rows.map((row) => ({
|
|
345
|
+
id: row.id,
|
|
346
|
+
kind: row.kind,
|
|
347
|
+
jobId: row.job_id,
|
|
348
|
+
occurrenceId: row.occurrence_id,
|
|
349
|
+
message: row.message,
|
|
350
|
+
createdAt: row.created_at,
|
|
351
|
+
acknowledgedAt: row.acknowledged_at,
|
|
352
|
+
occurrence: this.requireOccurrence(row.occurrence_id),
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
listInboxForJobs(jobIds, options = {}) {
|
|
356
|
+
const limit = options.limit ?? 100;
|
|
357
|
+
finiteInteger("limit", limit, 1, 10_000);
|
|
358
|
+
if (jobIds.length === 0)
|
|
359
|
+
return [];
|
|
360
|
+
for (const id of jobIds)
|
|
361
|
+
validateIdentity(id, "job id");
|
|
362
|
+
const rows = [];
|
|
363
|
+
for (let offset = 0; offset < jobIds.length; offset += 500) {
|
|
364
|
+
const chunk = jobIds.slice(offset, offset + 500);
|
|
365
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
366
|
+
const unread = options.unackedOnly ?? true ? "AND acknowledged_at IS NULL" : "";
|
|
367
|
+
rows.push(...this.db.prepare(`
|
|
368
|
+
SELECT * FROM scheduler_inbox
|
|
369
|
+
WHERE job_id IN (${placeholders}) ${unread}
|
|
370
|
+
ORDER BY created_at DESC, id DESC LIMIT ?
|
|
371
|
+
`).all(...chunk, limit));
|
|
372
|
+
}
|
|
373
|
+
return rows
|
|
374
|
+
.sort((left, right) => right.created_at - left.created_at || right.id - left.id)
|
|
375
|
+
.slice(0, limit)
|
|
376
|
+
.map((row) => ({
|
|
377
|
+
id: row.id,
|
|
378
|
+
kind: row.kind,
|
|
379
|
+
jobId: row.job_id,
|
|
380
|
+
occurrenceId: row.occurrence_id,
|
|
381
|
+
message: row.message,
|
|
382
|
+
createdAt: row.created_at,
|
|
383
|
+
acknowledgedAt: row.acknowledged_at,
|
|
384
|
+
occurrence: this.requireOccurrence(row.occurrence_id),
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
getInbox(id) {
|
|
388
|
+
finiteInteger("inbox id", id, 1);
|
|
389
|
+
const row = this.db.prepare("SELECT * FROM scheduler_inbox WHERE id = ?").get(id);
|
|
390
|
+
if (!row)
|
|
391
|
+
return null;
|
|
392
|
+
return {
|
|
393
|
+
id: row.id,
|
|
394
|
+
kind: row.kind,
|
|
395
|
+
jobId: row.job_id,
|
|
396
|
+
occurrenceId: row.occurrence_id,
|
|
397
|
+
message: row.message,
|
|
398
|
+
createdAt: row.created_at,
|
|
399
|
+
acknowledgedAt: row.acknowledged_at,
|
|
400
|
+
occurrence: this.requireOccurrence(row.occurrence_id),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
ackInbox(id, now = Date.now()) {
|
|
404
|
+
finiteInteger("inbox id", id, 1);
|
|
405
|
+
validateNow(now);
|
|
406
|
+
return this.db.prepare(`
|
|
407
|
+
UPDATE scheduler_inbox SET acknowledged_at = ?
|
|
408
|
+
WHERE id = ? AND acknowledged_at IS NULL
|
|
409
|
+
`).run(now, id).changes === 1;
|
|
410
|
+
}
|
|
411
|
+
listRuns(jobId, limit = 100) {
|
|
412
|
+
finiteInteger("limit", limit, 1, 10_000);
|
|
413
|
+
const rows = jobId
|
|
414
|
+
? this.db.prepare(`
|
|
415
|
+
SELECT * FROM scheduler_occurrences
|
|
416
|
+
WHERE job_id = ? ORDER BY scheduled_at DESC, id DESC LIMIT ?
|
|
417
|
+
`).all(jobId, limit)
|
|
418
|
+
: this.db.prepare(`
|
|
419
|
+
SELECT * FROM scheduler_occurrences
|
|
420
|
+
ORDER BY scheduled_at DESC, id DESC LIMIT ?
|
|
421
|
+
`).all(limit);
|
|
422
|
+
return rows.map(occurrenceFromRow);
|
|
423
|
+
}
|
|
424
|
+
runNow(jobId, now = Date.now()) {
|
|
425
|
+
validateNow(now);
|
|
426
|
+
const run = this.db.transaction(() => {
|
|
427
|
+
const job = this.requireJob(jobId);
|
|
428
|
+
if (job.blockedReason)
|
|
429
|
+
throw new Error(`Job is blocked: ${job.blockedReason}`);
|
|
430
|
+
if (!job.enabled)
|
|
431
|
+
throw new Error("Job must be enabled before it can run now");
|
|
432
|
+
if (job.kind === "session_prompt" && !this.isPersistentSession(job.sessionId)) {
|
|
433
|
+
throw new Error("Session must be persistent to run scheduled prompts");
|
|
434
|
+
}
|
|
435
|
+
const latest = this.db.prepare(`
|
|
436
|
+
SELECT MAX(scheduled_at) AS scheduled_at FROM scheduler_occurrences WHERE job_id = ?
|
|
437
|
+
`).get(jobId);
|
|
438
|
+
const scheduledAt = latest.scheduled_at !== null && latest.scheduled_at >= now
|
|
439
|
+
? latest.scheduled_at + 1
|
|
440
|
+
: now;
|
|
441
|
+
if (job.kind === "reminder") {
|
|
442
|
+
const occurrenceId = this.insertOccurrence(job.id, scheduledAt, "succeeded", now, now, null);
|
|
443
|
+
this.insertInbox("reminder", job, occurrenceId, job.message ?? "", now);
|
|
444
|
+
return this.requireOccurrence(occurrenceId);
|
|
445
|
+
}
|
|
446
|
+
const active = this.hasActiveOccurrence(job.id);
|
|
447
|
+
const state = active ? "skipped" : "pending";
|
|
448
|
+
const reason = active ? "A previous occurrence is still active" : null;
|
|
449
|
+
const occurrenceId = this.insertOccurrence(job.id, scheduledAt, state, now, state === "skipped" ? now : null, reason);
|
|
450
|
+
if (state === "skipped") {
|
|
451
|
+
this.insertInbox("command_result", job, occurrenceId, resultInboxMessage(job, state, reason), now);
|
|
452
|
+
}
|
|
453
|
+
return this.requireOccurrence(occurrenceId);
|
|
454
|
+
});
|
|
455
|
+
return run.immediate();
|
|
456
|
+
}
|
|
457
|
+
materialize(now = Date.now(), batchSize = 100) {
|
|
458
|
+
validateNow(now);
|
|
459
|
+
finiteInteger("batchSize", batchSize, 1, 10_000);
|
|
460
|
+
const materializeDue = this.db.transaction(() => {
|
|
461
|
+
const created = [];
|
|
462
|
+
for (let index = 0; index < batchSize; index += 1) {
|
|
463
|
+
const row = this.db.prepare(`
|
|
464
|
+
SELECT * FROM scheduler_jobs
|
|
465
|
+
WHERE enabled = 1 AND blocked_reason IS NULL AND next_due IS NOT NULL AND next_due <= ?
|
|
466
|
+
AND (session_id IS NULL OR EXISTS (
|
|
467
|
+
SELECT 1 FROM sessions s WHERE s.id = scheduler_jobs.session_id AND s.persistent = 1
|
|
468
|
+
))
|
|
469
|
+
ORDER BY next_due, created_at, id LIMIT 1
|
|
470
|
+
`).get(now);
|
|
471
|
+
if (!row)
|
|
472
|
+
break;
|
|
473
|
+
const job = jobFromRow(row);
|
|
474
|
+
const scheduledAt = job.nextDue;
|
|
475
|
+
if (scheduledAt === null)
|
|
476
|
+
break;
|
|
477
|
+
let state;
|
|
478
|
+
let reason = null;
|
|
479
|
+
if (job.kind === "reminder") {
|
|
480
|
+
state = "succeeded";
|
|
481
|
+
}
|
|
482
|
+
else if (now - scheduledAt > (job.startGraceMs ?? 0)) {
|
|
483
|
+
state = "skipped";
|
|
484
|
+
reason = job.kind === "session_prompt"
|
|
485
|
+
? "Occurrence exceeded its session prompt start grace period"
|
|
486
|
+
: "Occurrence exceeded its command start grace period";
|
|
487
|
+
}
|
|
488
|
+
else if (this.hasActiveOccurrence(job.id)) {
|
|
489
|
+
state = "skipped";
|
|
490
|
+
reason = "A previous occurrence is still active";
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
state = "pending";
|
|
494
|
+
}
|
|
495
|
+
const terminalAt = state === "pending" ? null : now;
|
|
496
|
+
const insert = this.db.prepare(`
|
|
497
|
+
INSERT OR IGNORE INTO scheduler_occurrences(
|
|
498
|
+
job_id, scheduled_at, state, created_at, started_at, finished_at, reason
|
|
499
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
500
|
+
`).run(job.id, scheduledAt, state, now, terminalAt, terminalAt, reason);
|
|
501
|
+
if (insert.changes === 1) {
|
|
502
|
+
const occurrenceId = Number(insert.lastInsertRowid);
|
|
503
|
+
if (job.kind === "reminder") {
|
|
504
|
+
this.insertInbox("reminder", job, occurrenceId, job.message ?? "", now);
|
|
505
|
+
}
|
|
506
|
+
else if (state === "skipped") {
|
|
507
|
+
this.insertInbox("command_result", job, occurrenceId, resultInboxMessage(job, state, reason), now);
|
|
508
|
+
}
|
|
509
|
+
created.push(this.requireOccurrence(occurrenceId));
|
|
510
|
+
}
|
|
511
|
+
if (job.scheduleKind === "once") {
|
|
512
|
+
this.db.prepare("UPDATE scheduler_jobs SET enabled = ?, next_due = NULL WHERE id = ?")
|
|
513
|
+
.run(job.kind !== "reminder" && state === "pending" ? 1 : 0, job.id);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
let nextDue = nextCronDue(job.schedule, job.timezone, scheduledAt);
|
|
517
|
+
let skippedCursors = 0;
|
|
518
|
+
while (nextDue <= now && skippedCursors < 10_000) {
|
|
519
|
+
nextDue = nextCronDue(job.schedule, job.timezone, nextDue);
|
|
520
|
+
skippedCursors += 1;
|
|
521
|
+
}
|
|
522
|
+
if (nextDue <= now)
|
|
523
|
+
nextDue = nextCronDue(job.schedule, job.timezone, now);
|
|
524
|
+
this.db.prepare("UPDATE scheduler_jobs SET next_due = ? WHERE id = ?").run(nextDue, job.id);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return created;
|
|
528
|
+
});
|
|
529
|
+
return materializeDue.immediate();
|
|
530
|
+
}
|
|
531
|
+
tick(now = Date.now(), batchSize = 100) {
|
|
532
|
+
return this.materialize(now, batchSize);
|
|
533
|
+
}
|
|
534
|
+
acquireLease(owner, bootId, now = Date.now(), leaseMs = 20_000) {
|
|
535
|
+
validateIdentity(owner, "owner");
|
|
536
|
+
validateIdentity(bootId, "bootId");
|
|
537
|
+
validateNow(now);
|
|
538
|
+
finiteInteger("leaseMs", leaseMs, 1, MAX_DURATION_MS);
|
|
539
|
+
const acquire = this.db.transaction(() => {
|
|
540
|
+
const current = this.db.prepare("SELECT * FROM scheduler_daemon_lease WHERE singleton = 1").get();
|
|
541
|
+
if (current && current.expires_at > now && (current.owner !== owner || current.boot_id !== bootId))
|
|
542
|
+
return false;
|
|
543
|
+
if (current && current.expires_at <= now)
|
|
544
|
+
this.recoverInterrupted(now);
|
|
545
|
+
if (current) {
|
|
546
|
+
this.db.prepare(`
|
|
547
|
+
UPDATE scheduler_daemon_lease
|
|
548
|
+
SET owner = ?, boot_id = ?, acquired_at = ?, renewed_at = ?, expires_at = ?
|
|
549
|
+
WHERE singleton = 1
|
|
550
|
+
`).run(owner, bootId, now, now, now + leaseMs);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
this.db.prepare(`
|
|
554
|
+
INSERT INTO scheduler_daemon_lease(singleton, owner, boot_id, acquired_at, renewed_at, expires_at)
|
|
555
|
+
VALUES (1, ?, ?, ?, ?, ?)
|
|
556
|
+
`).run(owner, bootId, now, now, now + leaseMs);
|
|
557
|
+
}
|
|
558
|
+
return true;
|
|
559
|
+
});
|
|
560
|
+
return acquire.immediate();
|
|
561
|
+
}
|
|
562
|
+
renewLease(owner, bootId, now = Date.now(), leaseMs = 20_000) {
|
|
563
|
+
validateIdentity(owner, "owner");
|
|
564
|
+
validateIdentity(bootId, "bootId");
|
|
565
|
+
validateNow(now);
|
|
566
|
+
finiteInteger("leaseMs", leaseMs, 1, MAX_DURATION_MS);
|
|
567
|
+
const renew = this.db.transaction(() => {
|
|
568
|
+
const result = this.db.prepare(`
|
|
569
|
+
UPDATE scheduler_daemon_lease SET renewed_at = ?, expires_at = ?
|
|
570
|
+
WHERE singleton = 1 AND owner = ? AND boot_id = ? AND expires_at > ?
|
|
571
|
+
`).run(now, now + leaseMs, owner, bootId, now);
|
|
572
|
+
if (result.changes !== 1)
|
|
573
|
+
return false;
|
|
574
|
+
this.db.prepare(`
|
|
575
|
+
UPDATE scheduler_occurrences SET claim_lease_expires_at = ?
|
|
576
|
+
WHERE state IN ('claimed', 'running') AND claim_owner = ? AND claim_boot_id = ?
|
|
577
|
+
`).run(now + leaseMs, owner, bootId);
|
|
578
|
+
return true;
|
|
579
|
+
});
|
|
580
|
+
return renew.immediate();
|
|
581
|
+
}
|
|
582
|
+
releaseLease(owner, bootId, now = Date.now()) {
|
|
583
|
+
validateNow(now);
|
|
584
|
+
const release = this.db.transaction(() => this.db.prepare(`
|
|
585
|
+
UPDATE scheduler_daemon_lease SET renewed_at = ?, expires_at = ?
|
|
586
|
+
WHERE singleton = 1 AND owner = ? AND boot_id = ?
|
|
587
|
+
`).run(now, now, owner, bootId).changes === 1);
|
|
588
|
+
return release.immediate();
|
|
589
|
+
}
|
|
590
|
+
getLease() {
|
|
591
|
+
const row = this.db.prepare("SELECT * FROM scheduler_daemon_lease WHERE singleton = 1").get();
|
|
592
|
+
return row ? leaseFromRow(row) : null;
|
|
593
|
+
}
|
|
594
|
+
claimCommands(owner, bootId, now = Date.now(), leaseMs = 20_000, limit = 1) {
|
|
595
|
+
validateIdentity(owner, "owner");
|
|
596
|
+
validateIdentity(bootId, "bootId");
|
|
597
|
+
validateNow(now);
|
|
598
|
+
finiteInteger("leaseMs", leaseMs, 1, MAX_DURATION_MS);
|
|
599
|
+
finiteInteger("limit", limit, 1, 1_000);
|
|
600
|
+
const claim = this.db.transaction(() => {
|
|
601
|
+
const lease = this.db.prepare(`
|
|
602
|
+
SELECT expires_at FROM scheduler_daemon_lease
|
|
603
|
+
WHERE singleton = 1 AND owner = ? AND boot_id = ? AND expires_at > ?
|
|
604
|
+
`).get(owner, bootId, now);
|
|
605
|
+
if (!lease)
|
|
606
|
+
return [];
|
|
607
|
+
const overdue = this.db.prepare(`
|
|
608
|
+
UPDATE scheduler_occurrences
|
|
609
|
+
SET state = 'skipped', started_at = ?, finished_at = ?,
|
|
610
|
+
reason = 'Occurrence exceeded its command start grace period'
|
|
611
|
+
WHERE state = 'pending' AND id IN (
|
|
612
|
+
SELECT o.id FROM scheduler_occurrences o
|
|
613
|
+
JOIN scheduler_jobs j ON j.id = o.job_id
|
|
614
|
+
WHERE o.state = 'pending' AND j.kind = 'command'
|
|
615
|
+
AND j.session_id IS NULL
|
|
616
|
+
AND o.scheduled_at + j.start_grace_ms < ?
|
|
617
|
+
)
|
|
618
|
+
RETURNING id, job_id
|
|
619
|
+
`).all(now, now, now);
|
|
620
|
+
for (const row of overdue) {
|
|
621
|
+
const job = this.requireCommandJob(row.job_id);
|
|
622
|
+
this.disableCompletedOneShot(job.id);
|
|
623
|
+
this.insertInbox("command_result", job, row.id, resultInboxMessage(job, "skipped", "Occurrence exceeded its command start grace period"), now);
|
|
624
|
+
}
|
|
625
|
+
const rows = this.db.prepare(`
|
|
626
|
+
SELECT o.* FROM scheduler_occurrences o
|
|
627
|
+
JOIN scheduler_jobs j ON j.id = o.job_id
|
|
628
|
+
WHERE o.state = 'pending' AND j.kind = 'command'
|
|
629
|
+
AND j.session_id IS NULL
|
|
630
|
+
AND j.enabled = 1 AND j.blocked_reason IS NULL
|
|
631
|
+
ORDER BY o.scheduled_at, o.id LIMIT ?
|
|
632
|
+
`).all(limit);
|
|
633
|
+
const claimed = [];
|
|
634
|
+
for (const row of rows) {
|
|
635
|
+
const token = randomUUID();
|
|
636
|
+
const claimExpiresAt = Math.min(now + leaseMs, lease.expires_at);
|
|
637
|
+
const result = this.db.prepare(`
|
|
638
|
+
UPDATE scheduler_occurrences
|
|
639
|
+
SET state = 'claimed', claim_token = ?, claim_owner = ?, claim_boot_id = ?,
|
|
640
|
+
claim_lease_expires_at = ?, claimed_at = ?
|
|
641
|
+
WHERE id = ? AND state = 'pending'
|
|
642
|
+
AND EXISTS (
|
|
643
|
+
SELECT 1 FROM scheduler_jobs j
|
|
644
|
+
WHERE j.id = scheduler_occurrences.job_id AND j.kind = 'command'
|
|
645
|
+
AND j.session_id IS NULL
|
|
646
|
+
AND j.enabled = 1 AND j.blocked_reason IS NULL
|
|
647
|
+
)
|
|
648
|
+
`).run(token, owner, bootId, claimExpiresAt, now, row.id);
|
|
649
|
+
if (result.changes !== 1)
|
|
650
|
+
continue;
|
|
651
|
+
const occurrence = this.requireOccurrence(row.id);
|
|
652
|
+
const job = this.requireCommandJob(row.job_id);
|
|
653
|
+
if (occurrence.state !== "claimed"
|
|
654
|
+
|| occurrence.claimToken === null
|
|
655
|
+
|| occurrence.claimOwner === null
|
|
656
|
+
|| occurrence.claimBootId === null
|
|
657
|
+
|| occurrence.claimLeaseExpiresAt === null) {
|
|
658
|
+
throw new Error("Claim transition did not persist");
|
|
659
|
+
}
|
|
660
|
+
claimed.push({
|
|
661
|
+
job,
|
|
662
|
+
occurrence: {
|
|
663
|
+
...occurrence,
|
|
664
|
+
state: "claimed",
|
|
665
|
+
claimToken: occurrence.claimToken,
|
|
666
|
+
claimOwner: occurrence.claimOwner,
|
|
667
|
+
claimBootId: occurrence.claimBootId,
|
|
668
|
+
claimLeaseExpiresAt: occurrence.claimLeaseExpiresAt,
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
return claimed;
|
|
673
|
+
});
|
|
674
|
+
return claim.immediate();
|
|
675
|
+
}
|
|
676
|
+
startClaimedCommand(occurrenceId, claimToken, owner, bootId, now = Date.now()) {
|
|
677
|
+
finiteInteger("occurrence id", occurrenceId, 1);
|
|
678
|
+
validateIdentity(claimToken, "claimToken");
|
|
679
|
+
validateIdentity(owner, "owner");
|
|
680
|
+
validateIdentity(bootId, "bootId");
|
|
681
|
+
validateNow(now);
|
|
682
|
+
const start = this.db.transaction(() => {
|
|
683
|
+
const lease = this.db.prepare(`
|
|
684
|
+
SELECT expires_at FROM scheduler_daemon_lease
|
|
685
|
+
WHERE singleton = 1 AND owner = ? AND boot_id = ? AND expires_at > ?
|
|
686
|
+
`).get(owner, bootId, now);
|
|
687
|
+
if (!lease)
|
|
688
|
+
return null;
|
|
689
|
+
const row = this.db.prepare(`
|
|
690
|
+
SELECT o.* FROM scheduler_occurrences o
|
|
691
|
+
WHERE o.id = ? AND o.claim_token = ? AND o.claim_owner = ? AND o.claim_boot_id = ?
|
|
692
|
+
AND o.claim_lease_expires_at > ?
|
|
693
|
+
AND EXISTS (
|
|
694
|
+
SELECT 1 FROM scheduler_jobs j
|
|
695
|
+
WHERE j.id = o.job_id AND j.kind = 'command' AND j.session_id IS NULL
|
|
696
|
+
)
|
|
697
|
+
`).get(occurrenceId, claimToken, owner, bootId, now);
|
|
698
|
+
if (!row)
|
|
699
|
+
return null;
|
|
700
|
+
if (row.state === "cancelled" || row.state === "skipped")
|
|
701
|
+
return occurrenceFromRow(row);
|
|
702
|
+
if (row.state !== "claimed")
|
|
703
|
+
return null;
|
|
704
|
+
const job = this.requireCommandJob(row.job_id);
|
|
705
|
+
if (!job.enabled || job.blockedReason !== null) {
|
|
706
|
+
const reason = job.blockedReason === "deleted" ? "Job deleted" : "Job paused";
|
|
707
|
+
const result = this.db.prepare(`
|
|
708
|
+
UPDATE scheduler_occurrences
|
|
709
|
+
SET state = 'cancelled', finished_at = ?, reason = ?
|
|
710
|
+
WHERE id = ? AND state = 'claimed' AND claim_token = ?
|
|
711
|
+
AND claim_owner = ? AND claim_boot_id = ? AND claim_lease_expires_at > ?
|
|
712
|
+
`).run(now, reason, occurrenceId, claimToken, owner, bootId, now);
|
|
713
|
+
if (result.changes !== 1)
|
|
714
|
+
return null;
|
|
715
|
+
this.disableCompletedOneShot(job.id);
|
|
716
|
+
this.insertInbox("command_result", job, occurrenceId, resultInboxMessage(job, "cancelled", reason), now);
|
|
717
|
+
return this.requireOccurrence(occurrenceId);
|
|
718
|
+
}
|
|
719
|
+
if (now - row.scheduled_at > (job.startGraceMs ?? 0)) {
|
|
720
|
+
const reason = "Occurrence exceeded its command start grace period";
|
|
721
|
+
const result = this.db.prepare(`
|
|
722
|
+
UPDATE scheduler_occurrences
|
|
723
|
+
SET state = 'skipped', started_at = ?, finished_at = ?, reason = ?
|
|
724
|
+
WHERE id = ? AND state = 'claimed' AND claim_token = ?
|
|
725
|
+
AND claim_owner = ? AND claim_boot_id = ? AND claim_lease_expires_at > ?
|
|
726
|
+
`).run(now, now, reason, occurrenceId, claimToken, owner, bootId, now);
|
|
727
|
+
if (result.changes !== 1)
|
|
728
|
+
return null;
|
|
729
|
+
this.disableCompletedOneShot(job.id);
|
|
730
|
+
this.insertInbox("command_result", job, occurrenceId, resultInboxMessage(job, "skipped", reason), now);
|
|
731
|
+
return this.requireOccurrence(occurrenceId);
|
|
732
|
+
}
|
|
733
|
+
const result = this.db.prepare(`
|
|
734
|
+
UPDATE scheduler_occurrences SET state = 'running', started_at = ?
|
|
735
|
+
WHERE id = ? AND state = 'claimed' AND claim_token = ?
|
|
736
|
+
AND claim_owner = ? AND claim_boot_id = ? AND claim_lease_expires_at > ?
|
|
737
|
+
AND scheduled_at + ? >= ?
|
|
738
|
+
AND EXISTS (
|
|
739
|
+
SELECT 1 FROM scheduler_jobs j
|
|
740
|
+
WHERE j.id = scheduler_occurrences.job_id
|
|
741
|
+
AND j.kind = 'command' AND j.session_id IS NULL
|
|
742
|
+
AND j.enabled = 1 AND j.blocked_reason IS NULL
|
|
743
|
+
)
|
|
744
|
+
AND EXISTS (
|
|
745
|
+
SELECT 1 FROM scheduler_daemon_lease l
|
|
746
|
+
WHERE l.singleton = 1 AND l.owner = ? AND l.boot_id = ? AND l.expires_at > ?
|
|
747
|
+
)
|
|
748
|
+
`).run(now, occurrenceId, claimToken, owner, bootId, now, job.startGraceMs ?? 0, now, owner, bootId, now);
|
|
749
|
+
return result.changes === 1 ? this.requireOccurrence(occurrenceId) : null;
|
|
750
|
+
});
|
|
751
|
+
return start.immediate();
|
|
752
|
+
}
|
|
753
|
+
releaseSessionPromptReservation(sessionId, owner, token) {
|
|
754
|
+
validateIdentity(sessionId, "sessionId");
|
|
755
|
+
validateIdentity(owner, "reservation owner");
|
|
756
|
+
validateIdentity(token, "reservation token");
|
|
757
|
+
return this.db.prepare(`
|
|
758
|
+
DELETE FROM session_operation_leases WHERE session_id = ? AND owner = ? AND token = ?
|
|
759
|
+
`).run(sessionId, owner, token).changes === 1;
|
|
760
|
+
}
|
|
761
|
+
finishCommand(occurrenceId, claimToken, owner, bootId, completion, now = Date.now()) {
|
|
762
|
+
finiteInteger("occurrence id", occurrenceId, 1);
|
|
763
|
+
validateIdentity(claimToken, "claimToken");
|
|
764
|
+
validateIdentity(owner, "owner");
|
|
765
|
+
validateIdentity(bootId, "bootId");
|
|
766
|
+
validateNow(now);
|
|
767
|
+
finiteInteger("stdoutBytes", completion.stdoutBytes, 0);
|
|
768
|
+
finiteInteger("stderrBytes", completion.stderrBytes, 0);
|
|
769
|
+
const finish = this.db.transaction(() => {
|
|
770
|
+
const row = this.db.prepare(`
|
|
771
|
+
SELECT j.* FROM scheduler_jobs j
|
|
772
|
+
JOIN scheduler_occurrences o ON o.job_id = j.id
|
|
773
|
+
JOIN scheduler_daemon_lease l ON l.singleton = 1
|
|
774
|
+
WHERE o.id = ? AND o.state = 'running' AND o.claim_token = ?
|
|
775
|
+
AND o.claim_owner = ? AND o.claim_boot_id = ? AND o.claim_lease_expires_at > ?
|
|
776
|
+
AND j.kind = 'command' AND j.session_id IS NULL
|
|
777
|
+
AND l.owner = ? AND l.boot_id = ? AND l.expires_at > ?
|
|
778
|
+
`).get(occurrenceId, claimToken, owner, bootId, now, owner, bootId, now);
|
|
779
|
+
if (!row)
|
|
780
|
+
return null;
|
|
781
|
+
const job = jobFromRow(row);
|
|
782
|
+
const outputLimit = job.outputBytes ?? 0;
|
|
783
|
+
const stdout = completion.stdout.subarray(0, outputLimit);
|
|
784
|
+
const stderr = completion.stderr.subarray(0, outputLimit);
|
|
785
|
+
const stdoutBytes = Math.max(completion.stdoutBytes, completion.stdout.byteLength);
|
|
786
|
+
const stderrBytes = Math.max(completion.stderrBytes, completion.stderr.byteLength);
|
|
787
|
+
const result = this.db.prepare(`
|
|
788
|
+
UPDATE scheduler_occurrences
|
|
789
|
+
SET state = ?, finished_at = ?, exit_code = ?, signal = ?, reason = ?,
|
|
790
|
+
stdout = ?, stderr = ?, stdout_bytes = ?, stderr_bytes = ?,
|
|
791
|
+
stdout_truncated = ?, stderr_truncated = ?
|
|
792
|
+
WHERE id = ? AND state = 'running' AND claim_token = ?
|
|
793
|
+
AND claim_owner = ? AND claim_boot_id = ? AND claim_lease_expires_at > ?
|
|
794
|
+
AND EXISTS (
|
|
795
|
+
SELECT 1 FROM scheduler_daemon_lease l
|
|
796
|
+
WHERE l.singleton = 1 AND l.owner = ? AND l.boot_id = ? AND l.expires_at > ?
|
|
797
|
+
)
|
|
798
|
+
`).run(completion.state, now, completion.exitCode, completion.signal, completion.reason, stdout, stderr, stdoutBytes, stderrBytes, completion.stdoutTruncated || stdoutBytes > outputLimit ? 1 : 0, completion.stderrTruncated || stderrBytes > outputLimit ? 1 : 0, occurrenceId, claimToken, owner, bootId, now, owner, bootId, now);
|
|
799
|
+
if (result.changes !== 1)
|
|
800
|
+
return null;
|
|
801
|
+
if (completion.state === "unknown")
|
|
802
|
+
this.blockUnknownJob(job.id);
|
|
803
|
+
else
|
|
804
|
+
this.disableCompletedOneShot(job.id);
|
|
805
|
+
this.insertInbox("command_result", job, occurrenceId, resultInboxMessage(job, completion.state, completion.reason), now);
|
|
806
|
+
return this.requireOccurrence(occurrenceId);
|
|
807
|
+
});
|
|
808
|
+
return finish.immediate();
|
|
809
|
+
}
|
|
810
|
+
claimSessionPrompts(owner, bootId, now = Date.now(), leaseMs = 20_000, limit = 1) {
|
|
811
|
+
validateIdentity(owner, "owner");
|
|
812
|
+
validateIdentity(bootId, "bootId");
|
|
813
|
+
validateNow(now);
|
|
814
|
+
finiteInteger("leaseMs", leaseMs, 1, MAX_DURATION_MS);
|
|
815
|
+
finiteInteger("limit", limit, 1, 1_000);
|
|
816
|
+
const claim = this.db.transaction(() => {
|
|
817
|
+
const lease = this.db.prepare(`
|
|
818
|
+
SELECT expires_at FROM scheduler_daemon_lease
|
|
819
|
+
WHERE singleton = 1 AND owner = ? AND boot_id = ? AND expires_at > ?
|
|
820
|
+
`).get(owner, bootId, now);
|
|
821
|
+
if (!lease)
|
|
822
|
+
return [];
|
|
823
|
+
const overdue = this.db.prepare(`
|
|
824
|
+
UPDATE scheduler_occurrences
|
|
825
|
+
SET state = 'skipped', started_at = ?, finished_at = ?,
|
|
826
|
+
reason = 'Occurrence exceeded its session prompt start grace period'
|
|
827
|
+
WHERE state = 'pending' AND id IN (
|
|
828
|
+
SELECT o.id FROM scheduler_occurrences o
|
|
829
|
+
JOIN scheduler_jobs j ON j.id = o.job_id
|
|
830
|
+
JOIN sessions s ON s.id = j.session_id
|
|
831
|
+
WHERE o.state = 'pending' AND j.kind = 'command'
|
|
832
|
+
AND j.session_id IS NOT NULL AND j.prompt IS NOT NULL
|
|
833
|
+
AND j.enabled = 1 AND j.blocked_reason IS NULL AND s.persistent = 1
|
|
834
|
+
AND o.scheduled_at + j.start_grace_ms < ?
|
|
835
|
+
)
|
|
836
|
+
RETURNING id, job_id
|
|
837
|
+
`).all(now, now, now);
|
|
838
|
+
for (const row of overdue) {
|
|
839
|
+
const job = this.requireSessionPromptJob(row.job_id);
|
|
840
|
+
this.disableCompletedOneShot(job.id);
|
|
841
|
+
this.insertInbox("command_result", job, row.id, resultInboxMessage(job, "skipped", "Occurrence exceeded its session prompt start grace period"), now);
|
|
842
|
+
}
|
|
843
|
+
const rows = this.db.prepare(`
|
|
844
|
+
SELECT o.* FROM scheduler_occurrences o
|
|
845
|
+
JOIN scheduler_jobs j ON j.id = o.job_id
|
|
846
|
+
JOIN sessions s ON s.id = j.session_id
|
|
847
|
+
WHERE o.state = 'pending' AND j.kind = 'command'
|
|
848
|
+
AND j.session_id IS NOT NULL AND j.prompt IS NOT NULL
|
|
849
|
+
AND j.enabled = 1 AND j.blocked_reason IS NULL AND s.persistent = 1
|
|
850
|
+
AND NOT EXISTS (
|
|
851
|
+
SELECT 1 FROM session_operation_leases sol
|
|
852
|
+
WHERE sol.session_id = j.session_id AND sol.expires_at > ?
|
|
853
|
+
)
|
|
854
|
+
AND NOT EXISTS (
|
|
855
|
+
SELECT 1 FROM scheduler_occurrences active
|
|
856
|
+
JOIN scheduler_jobs active_job ON active_job.id = active.job_id
|
|
857
|
+
WHERE active_job.session_id = j.session_id AND active.state IN ('claimed', 'running')
|
|
858
|
+
)
|
|
859
|
+
ORDER BY o.scheduled_at, o.id LIMIT ?
|
|
860
|
+
`).all(now, limit);
|
|
861
|
+
const claimed = [];
|
|
862
|
+
for (const row of rows) {
|
|
863
|
+
const token = randomUUID();
|
|
864
|
+
const claimExpiresAt = Math.min(now + leaseMs, lease.expires_at);
|
|
865
|
+
const result = this.db.prepare(`
|
|
866
|
+
UPDATE scheduler_occurrences
|
|
867
|
+
SET state = 'claimed', claim_token = ?, claim_owner = ?, claim_boot_id = ?,
|
|
868
|
+
claim_lease_expires_at = ?, claimed_at = ?
|
|
869
|
+
WHERE id = ? AND state = 'pending'
|
|
870
|
+
AND EXISTS (
|
|
871
|
+
SELECT 1 FROM scheduler_jobs j
|
|
872
|
+
JOIN sessions s ON s.id = j.session_id
|
|
873
|
+
WHERE j.id = scheduler_occurrences.job_id AND j.kind = 'command'
|
|
874
|
+
AND j.session_id IS NOT NULL AND j.prompt IS NOT NULL
|
|
875
|
+
AND j.enabled = 1 AND j.blocked_reason IS NULL AND s.persistent = 1
|
|
876
|
+
AND NOT EXISTS (
|
|
877
|
+
SELECT 1 FROM session_operation_leases sol
|
|
878
|
+
WHERE sol.session_id = j.session_id AND sol.expires_at > ?
|
|
879
|
+
)
|
|
880
|
+
AND NOT EXISTS (
|
|
881
|
+
SELECT 1 FROM scheduler_occurrences active
|
|
882
|
+
JOIN scheduler_jobs active_job ON active_job.id = active.job_id
|
|
883
|
+
WHERE active_job.session_id = j.session_id
|
|
884
|
+
AND active.id <> scheduler_occurrences.id
|
|
885
|
+
AND active.state IN ('claimed', 'running')
|
|
886
|
+
)
|
|
887
|
+
)
|
|
888
|
+
`).run(token, owner, bootId, claimExpiresAt, now, row.id, now);
|
|
889
|
+
if (result.changes !== 1)
|
|
890
|
+
continue;
|
|
891
|
+
const occurrence = this.requireOccurrence(row.id);
|
|
892
|
+
const job = this.requireSessionPromptJob(row.job_id);
|
|
893
|
+
if (occurrence.state !== "claimed"
|
|
894
|
+
|| occurrence.claimToken === null
|
|
895
|
+
|| occurrence.claimOwner === null
|
|
896
|
+
|| occurrence.claimBootId === null
|
|
897
|
+
|| occurrence.claimLeaseExpiresAt === null) {
|
|
898
|
+
throw new Error("Session prompt claim transition did not persist");
|
|
899
|
+
}
|
|
900
|
+
claimed.push({
|
|
901
|
+
job,
|
|
902
|
+
occurrence: {
|
|
903
|
+
...occurrence,
|
|
904
|
+
state: "claimed",
|
|
905
|
+
claimToken: occurrence.claimToken,
|
|
906
|
+
claimOwner: occurrence.claimOwner,
|
|
907
|
+
claimBootId: occurrence.claimBootId,
|
|
908
|
+
claimLeaseExpiresAt: occurrence.claimLeaseExpiresAt,
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
return claimed;
|
|
913
|
+
});
|
|
914
|
+
return claim.immediate();
|
|
915
|
+
}
|
|
916
|
+
startClaimedSessionPrompt(occurrenceId, claimToken, owner, bootId, now = Date.now()) {
|
|
917
|
+
finiteInteger("occurrence id", occurrenceId, 1);
|
|
918
|
+
validateIdentity(claimToken, "claimToken");
|
|
919
|
+
validateIdentity(owner, "owner");
|
|
920
|
+
validateIdentity(bootId, "bootId");
|
|
921
|
+
validateNow(now);
|
|
922
|
+
const start = this.db.transaction(() => {
|
|
923
|
+
const lease = this.db.prepare(`
|
|
924
|
+
SELECT expires_at FROM scheduler_daemon_lease
|
|
925
|
+
WHERE singleton = 1 AND owner = ? AND boot_id = ? AND expires_at > ?
|
|
926
|
+
`).get(owner, bootId, now);
|
|
927
|
+
if (!lease)
|
|
928
|
+
return null;
|
|
929
|
+
const row = this.db.prepare(`
|
|
930
|
+
SELECT o.* FROM scheduler_occurrences o
|
|
931
|
+
WHERE o.id = ? AND o.claim_token = ? AND o.claim_owner = ? AND o.claim_boot_id = ?
|
|
932
|
+
AND o.claim_lease_expires_at > ?
|
|
933
|
+
AND EXISTS (
|
|
934
|
+
SELECT 1 FROM scheduler_jobs j
|
|
935
|
+
WHERE j.id = o.job_id AND j.kind = 'command'
|
|
936
|
+
AND j.session_id IS NOT NULL AND j.prompt IS NOT NULL
|
|
937
|
+
)
|
|
938
|
+
`).get(occurrenceId, claimToken, owner, bootId, now);
|
|
939
|
+
if (!row)
|
|
940
|
+
return null;
|
|
941
|
+
if (row.state === "cancelled" || row.state === "skipped")
|
|
942
|
+
return occurrenceFromRow(row);
|
|
943
|
+
if (row.state !== "claimed")
|
|
944
|
+
return null;
|
|
945
|
+
const job = this.requireSessionPromptJob(row.job_id);
|
|
946
|
+
const persistent = this.isPersistentSession(job.sessionId);
|
|
947
|
+
if (!job.enabled || job.blockedReason !== null || !persistent) {
|
|
948
|
+
const reason = job.blockedReason === "deleted"
|
|
949
|
+
? "Job deleted"
|
|
950
|
+
: !persistent
|
|
951
|
+
? "Session is no longer persistent"
|
|
952
|
+
: "Job paused";
|
|
953
|
+
const result = this.db.prepare(`
|
|
954
|
+
UPDATE scheduler_occurrences
|
|
955
|
+
SET state = 'cancelled', finished_at = ?, reason = ?
|
|
956
|
+
WHERE id = ? AND state = 'claimed' AND claim_token = ?
|
|
957
|
+
AND claim_owner = ? AND claim_boot_id = ? AND claim_lease_expires_at > ?
|
|
958
|
+
`).run(now, reason, occurrenceId, claimToken, owner, bootId, now);
|
|
959
|
+
if (result.changes !== 1)
|
|
960
|
+
return null;
|
|
961
|
+
this.disableCompletedOneShot(job.id);
|
|
962
|
+
this.insertInbox("command_result", job, occurrenceId, resultInboxMessage(job, "cancelled", reason), now);
|
|
963
|
+
return this.requireOccurrence(occurrenceId);
|
|
964
|
+
}
|
|
965
|
+
const sessionBusy = this.db.prepare(`
|
|
966
|
+
SELECT 1 FROM session_operation_leases WHERE session_id = ? AND expires_at > ?
|
|
967
|
+
`).get(job.sessionId, now);
|
|
968
|
+
if (sessionBusy) {
|
|
969
|
+
const deferred = this.db.prepare(`
|
|
970
|
+
UPDATE scheduler_occurrences
|
|
971
|
+
SET state = 'pending', claim_token = NULL, claim_owner = NULL,
|
|
972
|
+
claim_boot_id = NULL, claim_lease_expires_at = NULL, claimed_at = NULL
|
|
973
|
+
WHERE id = ? AND state = 'claimed' AND claim_token = ?
|
|
974
|
+
AND claim_owner = ? AND claim_boot_id = ?
|
|
975
|
+
`).run(occurrenceId, claimToken, owner, bootId);
|
|
976
|
+
return deferred.changes === 1 ? this.requireOccurrence(occurrenceId) : null;
|
|
977
|
+
}
|
|
978
|
+
if (now - row.scheduled_at > (job.startGraceMs ?? 0)) {
|
|
979
|
+
const reason = "Occurrence exceeded its session prompt start grace period";
|
|
980
|
+
const result = this.db.prepare(`
|
|
981
|
+
UPDATE scheduler_occurrences
|
|
982
|
+
SET state = 'skipped', started_at = ?, finished_at = ?, reason = ?
|
|
983
|
+
WHERE id = ? AND state = 'claimed' AND claim_token = ?
|
|
984
|
+
AND claim_owner = ? AND claim_boot_id = ? AND claim_lease_expires_at > ?
|
|
985
|
+
`).run(now, now, reason, occurrenceId, claimToken, owner, bootId, now);
|
|
986
|
+
if (result.changes !== 1)
|
|
987
|
+
return null;
|
|
988
|
+
this.disableCompletedOneShot(job.id);
|
|
989
|
+
this.insertInbox("command_result", job, occurrenceId, resultInboxMessage(job, "skipped", reason), now);
|
|
990
|
+
return this.requireOccurrence(occurrenceId);
|
|
991
|
+
}
|
|
992
|
+
const reservation = sessionPromptReservation(owner, bootId, claimToken);
|
|
993
|
+
this.db.prepare(`
|
|
994
|
+
DELETE FROM session_operation_leases WHERE session_id = ? AND expires_at <= ?
|
|
995
|
+
`).run(job.sessionId, now);
|
|
996
|
+
const reserved = this.db.prepare(`
|
|
997
|
+
INSERT OR IGNORE INTO session_operation_leases(
|
|
998
|
+
session_id, owner, token, kind, acquired_at, renewed_at, expires_at
|
|
999
|
+
) VALUES (?, ?, ?, 'turn', ?, ?, ?)
|
|
1000
|
+
`).run(job.sessionId, reservation.owner, reservation.token, now, now, lease.expires_at);
|
|
1001
|
+
if (reserved.changes !== 1)
|
|
1002
|
+
return null;
|
|
1003
|
+
const result = this.db.prepare(`
|
|
1004
|
+
UPDATE scheduler_occurrences SET state = 'running', started_at = ?
|
|
1005
|
+
WHERE id = ? AND state = 'claimed' AND claim_token = ?
|
|
1006
|
+
AND claim_owner = ? AND claim_boot_id = ? AND claim_lease_expires_at > ?
|
|
1007
|
+
AND scheduled_at + ? >= ?
|
|
1008
|
+
AND EXISTS (
|
|
1009
|
+
SELECT 1 FROM scheduler_jobs j
|
|
1010
|
+
JOIN sessions s ON s.id = j.session_id
|
|
1011
|
+
WHERE j.id = scheduler_occurrences.job_id AND j.kind = 'command'
|
|
1012
|
+
AND j.session_id IS NOT NULL AND j.prompt IS NOT NULL
|
|
1013
|
+
AND j.enabled = 1 AND j.blocked_reason IS NULL AND s.persistent = 1
|
|
1014
|
+
)
|
|
1015
|
+
AND EXISTS (
|
|
1016
|
+
SELECT 1 FROM scheduler_daemon_lease l
|
|
1017
|
+
WHERE l.singleton = 1 AND l.owner = ? AND l.boot_id = ? AND l.expires_at > ?
|
|
1018
|
+
)
|
|
1019
|
+
`).run(now, occurrenceId, claimToken, owner, bootId, now, job.startGraceMs ?? 0, now, owner, bootId, now);
|
|
1020
|
+
if (result.changes !== 1) {
|
|
1021
|
+
this.releaseSessionPromptReservation(job.sessionId, reservation.owner, reservation.token);
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
return this.requireOccurrence(occurrenceId);
|
|
1025
|
+
});
|
|
1026
|
+
return start.immediate();
|
|
1027
|
+
}
|
|
1028
|
+
finishSessionPrompt(occurrenceId, claimToken, owner, bootId, completion, now = Date.now()) {
|
|
1029
|
+
finiteInteger("occurrence id", occurrenceId, 1);
|
|
1030
|
+
validateIdentity(claimToken, "claimToken");
|
|
1031
|
+
validateIdentity(owner, "owner");
|
|
1032
|
+
validateIdentity(bootId, "bootId");
|
|
1033
|
+
validateNow(now);
|
|
1034
|
+
if (!["succeeded", "failed", "cancelled", "unknown"].includes(completion.state)) {
|
|
1035
|
+
throw new Error(`Invalid session prompt completion state: ${String(completion.state)}`);
|
|
1036
|
+
}
|
|
1037
|
+
const output = completion.output ?? completion.result ?? "";
|
|
1038
|
+
const error = completion.error ?? "";
|
|
1039
|
+
if (typeof output !== "string")
|
|
1040
|
+
throw new Error("Session prompt output must be a string");
|
|
1041
|
+
if (typeof error !== "string")
|
|
1042
|
+
throw new Error("Session prompt error must be a string");
|
|
1043
|
+
const finish = this.db.transaction(() => {
|
|
1044
|
+
const row = this.db.prepare(`
|
|
1045
|
+
SELECT j.* FROM scheduler_jobs j
|
|
1046
|
+
JOIN scheduler_occurrences o ON o.job_id = j.id
|
|
1047
|
+
JOIN scheduler_daemon_lease l ON l.singleton = 1
|
|
1048
|
+
WHERE o.id = ? AND o.state = 'running' AND o.claim_token = ?
|
|
1049
|
+
AND o.claim_owner = ? AND o.claim_boot_id = ? AND o.claim_lease_expires_at > ?
|
|
1050
|
+
AND j.kind = 'command' AND j.session_id IS NOT NULL AND j.prompt IS NOT NULL
|
|
1051
|
+
AND l.owner = ? AND l.boot_id = ? AND l.expires_at > ?
|
|
1052
|
+
`).get(occurrenceId, claimToken, owner, bootId, now, owner, bootId, now);
|
|
1053
|
+
if (!row)
|
|
1054
|
+
return null;
|
|
1055
|
+
const job = this.requireSessionPromptJob(row.id);
|
|
1056
|
+
const outputLimit = job.outputBytes ?? 0;
|
|
1057
|
+
const stdout = boundedUtf8(output, outputLimit);
|
|
1058
|
+
const stderr = boundedUtf8(error, outputLimit);
|
|
1059
|
+
const reason = completion.reason ?? (completion.state === "failed" && error ? error : null);
|
|
1060
|
+
const result = this.db.prepare(`
|
|
1061
|
+
UPDATE scheduler_occurrences
|
|
1062
|
+
SET state = ?, finished_at = ?, exit_code = NULL, signal = NULL, reason = ?,
|
|
1063
|
+
stdout = ?, stderr = ?, stdout_bytes = ?, stderr_bytes = ?,
|
|
1064
|
+
stdout_truncated = ?, stderr_truncated = ?
|
|
1065
|
+
WHERE id = ? AND state = 'running' AND claim_token = ?
|
|
1066
|
+
AND claim_owner = ? AND claim_boot_id = ? AND claim_lease_expires_at > ?
|
|
1067
|
+
AND EXISTS (
|
|
1068
|
+
SELECT 1 FROM scheduler_daemon_lease l
|
|
1069
|
+
WHERE l.singleton = 1 AND l.owner = ? AND l.boot_id = ? AND l.expires_at > ?
|
|
1070
|
+
)
|
|
1071
|
+
`).run(completion.state, now, reason, stdout.buffer, stderr.buffer, stdout.bytes, stderr.bytes, stdout.truncated ? 1 : 0, stderr.truncated ? 1 : 0, occurrenceId, claimToken, owner, bootId, now, owner, bootId, now);
|
|
1072
|
+
if (result.changes !== 1)
|
|
1073
|
+
return null;
|
|
1074
|
+
const reservation = sessionPromptReservation(owner, bootId, claimToken);
|
|
1075
|
+
this.releaseSessionPromptReservation(job.sessionId, reservation.owner, reservation.token);
|
|
1076
|
+
if (completion.state === "unknown")
|
|
1077
|
+
this.blockUnknownJob(job.id);
|
|
1078
|
+
else
|
|
1079
|
+
this.disableCompletedOneShot(job.id);
|
|
1080
|
+
this.insertInbox("command_result", job, occurrenceId, resultInboxMessage(job, completion.state, reason), now);
|
|
1081
|
+
return this.requireOccurrence(occurrenceId);
|
|
1082
|
+
});
|
|
1083
|
+
return finish.immediate();
|
|
1084
|
+
}
|
|
1085
|
+
cancelUnstarted(jobId, reason, now) {
|
|
1086
|
+
const rows = this.db.prepare(`
|
|
1087
|
+
UPDATE scheduler_occurrences
|
|
1088
|
+
SET state = 'cancelled', finished_at = ?, reason = ?
|
|
1089
|
+
WHERE job_id = ? AND state IN ('pending', 'claimed')
|
|
1090
|
+
RETURNING id
|
|
1091
|
+
`).all(now, reason, jobId);
|
|
1092
|
+
const job = this.requireJob(jobId);
|
|
1093
|
+
if (job.kind === "reminder")
|
|
1094
|
+
return;
|
|
1095
|
+
for (const row of rows) {
|
|
1096
|
+
this.insertInbox("command_result", job, row.id, resultInboxMessage(job, "cancelled", reason), now);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
recoverInterrupted(now) {
|
|
1100
|
+
const interrupted = this.db.prepare(`
|
|
1101
|
+
UPDATE scheduler_occurrences
|
|
1102
|
+
SET state = 'unknown', finished_at = ?, reason = 'Daemon lease expired before the result was recorded'
|
|
1103
|
+
WHERE state IN ('claimed', 'running')
|
|
1104
|
+
RETURNING id, job_id
|
|
1105
|
+
`).all(now);
|
|
1106
|
+
for (const row of interrupted) {
|
|
1107
|
+
const job = this.requireJob(row.job_id);
|
|
1108
|
+
if (job.kind !== "reminder") {
|
|
1109
|
+
this.insertInbox("command_result", job, row.id, resultInboxMessage(job, "unknown", "Daemon lease expired before the result was recorded"), now);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
this.db.prepare(`
|
|
1113
|
+
UPDATE scheduler_jobs
|
|
1114
|
+
SET enabled = 0, blocked_reason = 'An occurrence has an unknown outcome'
|
|
1115
|
+
WHERE kind = 'command' AND schedule_kind = 'cron' AND id IN (
|
|
1116
|
+
SELECT job_id FROM scheduler_occurrences WHERE state = 'unknown'
|
|
1117
|
+
)
|
|
1118
|
+
`).run();
|
|
1119
|
+
this.db.prepare(`
|
|
1120
|
+
UPDATE scheduler_jobs SET enabled = 0
|
|
1121
|
+
WHERE kind = 'command' AND schedule_kind = 'once' AND id IN (
|
|
1122
|
+
SELECT job_id FROM scheduler_occurrences WHERE state = 'unknown'
|
|
1123
|
+
)
|
|
1124
|
+
`).run();
|
|
1125
|
+
}
|
|
1126
|
+
disableCompletedOneShot(jobId) {
|
|
1127
|
+
this.db.prepare(`
|
|
1128
|
+
UPDATE scheduler_jobs SET enabled = 0
|
|
1129
|
+
WHERE id = ? AND schedule_kind = 'once' AND next_due IS NULL
|
|
1130
|
+
`).run(jobId);
|
|
1131
|
+
}
|
|
1132
|
+
blockUnknownJob(jobId) {
|
|
1133
|
+
this.db.prepare(`
|
|
1134
|
+
UPDATE scheduler_jobs
|
|
1135
|
+
SET enabled = 0, blocked_reason = CASE
|
|
1136
|
+
WHEN schedule_kind = 'cron' THEN 'An occurrence has an unknown outcome'
|
|
1137
|
+
ELSE blocked_reason
|
|
1138
|
+
END
|
|
1139
|
+
WHERE id = ?
|
|
1140
|
+
`).run(jobId);
|
|
1141
|
+
}
|
|
1142
|
+
insertOccurrence(jobId, scheduledAt, state, createdAt, finishedAt, reason) {
|
|
1143
|
+
const result = this.db.prepare(`
|
|
1144
|
+
INSERT INTO scheduler_occurrences(
|
|
1145
|
+
job_id, scheduled_at, state, created_at, started_at, finished_at, reason
|
|
1146
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1147
|
+
`).run(jobId, scheduledAt, state, createdAt, finishedAt, finishedAt, reason);
|
|
1148
|
+
return Number(result.lastInsertRowid);
|
|
1149
|
+
}
|
|
1150
|
+
insertInbox(kind, job, occurrenceId, message, now) {
|
|
1151
|
+
this.db.prepare(`
|
|
1152
|
+
INSERT OR IGNORE INTO scheduler_inbox(kind, job_id, occurrence_id, message, created_at)
|
|
1153
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1154
|
+
`).run(kind, job.id, occurrenceId, message, now);
|
|
1155
|
+
}
|
|
1156
|
+
hasActiveOccurrence(jobId) {
|
|
1157
|
+
return Boolean(this.db.prepare(`
|
|
1158
|
+
SELECT 1 FROM scheduler_occurrences
|
|
1159
|
+
WHERE job_id = ? AND state IN ('pending', 'claimed', 'running') LIMIT 1
|
|
1160
|
+
`).get(jobId));
|
|
1161
|
+
}
|
|
1162
|
+
requireJob(id) {
|
|
1163
|
+
const job = this.getJob(id);
|
|
1164
|
+
if (!job)
|
|
1165
|
+
throw new Error(`Scheduler job not found: ${id}`);
|
|
1166
|
+
return job;
|
|
1167
|
+
}
|
|
1168
|
+
requireCommandJob(id) {
|
|
1169
|
+
const job = this.requireJob(id);
|
|
1170
|
+
if (job.kind !== "command" || job.sessionId !== null || job.command === null || job.cwd === null) {
|
|
1171
|
+
throw new Error(`Scheduler job is not a command: ${id}`);
|
|
1172
|
+
}
|
|
1173
|
+
return { ...job, kind: "command", command: job.command, cwd: job.cwd };
|
|
1174
|
+
}
|
|
1175
|
+
requireSessionPromptJob(id) {
|
|
1176
|
+
const job = this.requireJob(id);
|
|
1177
|
+
if (job.kind !== "session_prompt"
|
|
1178
|
+
|| job.sessionId === null
|
|
1179
|
+
|| job.prompt === null
|
|
1180
|
+
|| job.cwd === null) {
|
|
1181
|
+
throw new Error(`Scheduler job is not a session prompt: ${id}`);
|
|
1182
|
+
}
|
|
1183
|
+
return {
|
|
1184
|
+
...job,
|
|
1185
|
+
kind: "session_prompt",
|
|
1186
|
+
sessionId: job.sessionId,
|
|
1187
|
+
prompt: job.prompt,
|
|
1188
|
+
cwd: job.cwd,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
isPersistentSession(sessionId) {
|
|
1192
|
+
if (sessionId === null)
|
|
1193
|
+
return false;
|
|
1194
|
+
return Boolean(this.db.prepare("SELECT 1 FROM sessions WHERE id = ? AND persistent = 1").get(sessionId));
|
|
1195
|
+
}
|
|
1196
|
+
requireOccurrence(id) {
|
|
1197
|
+
const row = this.db.prepare("SELECT * FROM scheduler_occurrences WHERE id = ?").get(id);
|
|
1198
|
+
if (!row)
|
|
1199
|
+
throw new Error(`Scheduler occurrence not found: ${id}`);
|
|
1200
|
+
return occurrenceFromRow(row);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
//# sourceMappingURL=store.js.map
|