@shawnstack/quickforge 1.7.7 → 1.7.8

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.
Files changed (83) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/cloud/auto-approval.mjs +155 -0
  29. package/server/cloud/client.mjs +5 -0
  30. package/server/cloud/qf-agent-process.mjs +163 -3
  31. package/server/cloud/service-config.mjs +1 -1
  32. package/server/index.mjs +69 -13
  33. package/server/lan-access-backup.mjs +256 -0
  34. package/server/lan-access-cutover.mjs +306 -0
  35. package/server/lan-access-json-file.mjs +75 -0
  36. package/server/lan-access-service.mjs +155 -0
  37. package/server/lan-access-store.mjs +110 -9
  38. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  39. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  40. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  41. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  42. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  43. package/server/maintenance/export-session-state-v1.mjs +67 -0
  44. package/server/maintenance/export-share-v1.mjs +77 -0
  45. package/server/routes/agent.mjs +37 -4
  46. package/server/routes/backup.mjs +221 -39
  47. package/server/routes/cloud.mjs +28 -1
  48. package/server/routes/scheduled-tasks.mjs +347 -134
  49. package/server/routes/shares.mjs +4 -2
  50. package/server/routes/storage.mjs +138 -8
  51. package/server/routes/workspace.mjs +265 -4
  52. package/server/scheduled-runs-backup.mjs +235 -0
  53. package/server/scheduled-runs-cutover.mjs +497 -0
  54. package/server/scheduled-task-runs-service.mjs +279 -0
  55. package/server/session-index-service.mjs +495 -0
  56. package/server/session-state-backup.mjs +277 -0
  57. package/server/session-state-cutover.mjs +370 -0
  58. package/server/session-state-service.mjs +709 -0
  59. package/server/share-backup.mjs +255 -0
  60. package/server/share-cutover.mjs +296 -0
  61. package/server/share-json-file.mjs +60 -0
  62. package/server/share-service.mjs +163 -0
  63. package/server/share-store.mjs +196 -19
  64. package/server/skills.mjs +0 -1
  65. package/server/sqlite/database.mjs +233 -0
  66. package/server/sqlite/lan-access-repository.mjs +652 -0
  67. package/server/sqlite/migrations.mjs +577 -0
  68. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  69. package/server/sqlite/session-index-repository.mjs +297 -0
  70. package/server/sqlite/session-state-repository.mjs +786 -0
  71. package/server/sqlite/share-repository.mjs +854 -0
  72. package/server/storage.mjs +317 -1
  73. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  74. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  75. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  76. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  77. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  78. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  79. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  80. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  81. package/dist/assets/index-BTPzyq7d.js +0 -65
  82. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  83. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -0,0 +1,464 @@
1
+ import { getSqliteStorage } from './database.mjs'
2
+
3
+ export const MAX_SCHEDULED_TASK_RUNS_PER_TASK = 200
4
+
5
+ const RUN_STATUSES = new Set(['running', 'success', 'failed'])
6
+ const RUN_TRIGGERS = new Set(['schedule', 'manual'])
7
+ const TEXT_FIELDS = Object.freeze({
8
+ inputContent: 'input_content',
9
+ aiResult: 'ai_result',
10
+ result: 'result',
11
+ errorMessage: 'error_message',
12
+ warning: 'warning',
13
+ sessionId: 'session_id',
14
+ finishedAt: 'finished_at',
15
+ agentId: 'agent_id',
16
+ agentLabel: 'agent_label',
17
+ })
18
+ const OPTIONAL_COLUMNS = Object.freeze({
19
+ trigger: 'trigger',
20
+ inputContent: 'input_content',
21
+ aiResult: 'ai_result',
22
+ result: 'result',
23
+ errorMessage: 'error_message',
24
+ warning: 'warning',
25
+ sessionId: 'session_id',
26
+ scheduledAt: 'scheduled_at',
27
+ finishedAt: 'finished_at',
28
+ durationMs: 'duration_ms',
29
+ })
30
+ const STORED_FIELDS = new Set([
31
+ 'id', 'taskId', 'status', 'trigger', 'inputContent', 'aiResult', 'result', 'errorMessage',
32
+ 'warning', 'sessionId', 'scheduledAt', 'startedAt', 'finishedAt', 'durationMs', 'agentId',
33
+ 'agentLabel', 'agentSnapshot', 'legacy', 'source', 'updatedAt',
34
+ ])
35
+ const FILTER_FIELDS = new Set([
36
+ 'taskId', 'taskIds', 'status', 'trigger', 'startedFrom', 'startedTo', 'keyword',
37
+ 'keywordTaskIds', 'page', 'pageSize',
38
+ ])
39
+ const IMMUTABLE_UPDATE_FIELDS = new Set(['id', 'taskId', 'startedAt', 'trigger', 'scheduledAt', 'source'])
40
+ const UPDATE_FIELDS = new Set(['status', ...Object.keys(TEXT_FIELDS), 'durationMs', 'agentSnapshot', 'legacy'])
41
+
42
+ function assertStorage(storage) {
43
+ if (!storage || typeof storage.prepare !== 'function' || typeof storage.transaction !== 'function') {
44
+ throw new TypeError('Scheduled task runs repository requires a SQLite storage handle')
45
+ }
46
+ return storage
47
+ }
48
+
49
+ function assertNonEmptyString(value, field) {
50
+ if (typeof value !== 'string' || value.trim().length === 0) throw new TypeError(`${field} must be a non-empty string`)
51
+ return value
52
+ }
53
+
54
+ function assertNullableString(value, field) {
55
+ if (value !== null && typeof value !== 'string') throw new TypeError(`${field} must be a string or null`)
56
+ return value
57
+ }
58
+
59
+ function assertStatus(value) {
60
+ if (typeof value !== 'string' || !RUN_STATUSES.has(value)) throw new TypeError('status must be running, success, or failed')
61
+ return value
62
+ }
63
+
64
+ function assertTrigger(value, { nullable = true } = {}) {
65
+ if (nullable && value === null) return null
66
+ if (typeof value !== 'string' || !RUN_TRIGGERS.has(value)) throw new TypeError('trigger must be schedule, manual, or null')
67
+ return value
68
+ }
69
+
70
+ function assertDuration(value) {
71
+ if (value !== null && (!Number.isInteger(value) || value < 0)) throw new TypeError('durationMs must be a non-negative integer or null')
72
+ return value
73
+ }
74
+
75
+ function assertNullablePlainObject(value, field) {
76
+ if (value !== null && (typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype)) {
77
+ throw new TypeError(`${field} must be a plain object or null`)
78
+ }
79
+ return value
80
+ }
81
+
82
+ function serializeJsonObject(value, field, { nullable = true } = {}) {
83
+ if (value === null && nullable) return null
84
+ assertNullablePlainObject(value, field)
85
+ try {
86
+ const serialized = JSON.stringify(value)
87
+ if (typeof serialized !== 'string') throw new TypeError('not serializable')
88
+ return serialized
89
+ } catch {
90
+ throw new TypeError(`${field} must be JSON-serializable`)
91
+ }
92
+ }
93
+
94
+ function cloneJsonValue(value, field) {
95
+ try {
96
+ const serialized = JSON.stringify(value)
97
+ if (serialized === undefined) throw new TypeError('not serializable')
98
+ return JSON.parse(serialized)
99
+ } catch {
100
+ throw new TypeError(`${field} must be JSON-serializable`)
101
+ }
102
+ }
103
+
104
+ function parseJsonObject(value, fallback) {
105
+ return value === null ? fallback : JSON.parse(value)
106
+ }
107
+
108
+ function extraFields(run) {
109
+ const extra = {}
110
+ for (const [field, value] of Object.entries(run)) {
111
+ if (!STORED_FIELDS.has(field) && value !== undefined) extra[field] = cloneJsonValue(value, field)
112
+ }
113
+ return extra
114
+ }
115
+
116
+ function mapRow(row, { includeTaskId = false } = {}) {
117
+ if (!row) return null
118
+ const extra = parseJsonObject(row.extra_json, {})
119
+ const run = {
120
+ ...extra,
121
+ id: row.id,
122
+ status: row.status,
123
+ startedAt: row.started_at,
124
+ source: row.source,
125
+ }
126
+ if (includeTaskId) run.taskId = row.task_id
127
+ for (const [field, column] of Object.entries(OPTIONAL_COLUMNS)) {
128
+ if (row[column] !== null) run[field] = row[column]
129
+ }
130
+ run.agentId = row.agent_id
131
+ run.agentLabel = row.agent_label
132
+ run.agentSnapshot = parseJsonObject(row.agent_snapshot_json, null)
133
+ const legacy = parseJsonObject(row.legacy_json, null)
134
+ if (legacy !== null) run.legacy = legacy
135
+ return run
136
+ }
137
+
138
+ function normalizeFullRun(taskId, run, { source, now = () => new Date().toISOString() } = {}) {
139
+ assertNonEmptyString(taskId, 'taskId')
140
+ if (!run || typeof run !== 'object' || Array.isArray(run)) throw new TypeError('run must be an object')
141
+ const normalizedSource = assertNonEmptyString(source ?? run.source ?? 'runtime', 'source')
142
+ const values = {
143
+ taskId,
144
+ id: assertNonEmptyString(run.id, 'id'),
145
+ status: assertStatus(run.status),
146
+ trigger: run.trigger === undefined ? null : assertTrigger(run.trigger),
147
+ startedAt: assertNonEmptyString(run.startedAt, 'startedAt'),
148
+ durationMs: run.durationMs === undefined ? null : assertDuration(run.durationMs),
149
+ agentSnapshotJson: run.agentSnapshot === undefined ? null : serializeJsonObject(run.agentSnapshot, 'agentSnapshot'),
150
+ legacyJson: run.legacy === undefined ? null : serializeJsonObject(run.legacy, 'legacy'),
151
+ extraJson: serializeJsonObject(extraFields(run), 'extra', { nullable: false }),
152
+ source: normalizedSource,
153
+ updatedAt: assertNonEmptyString(run.updatedAt ?? now(), 'updatedAt'),
154
+ }
155
+ for (const field of Object.keys(TEXT_FIELDS)) values[field] = run[field] === undefined ? null : assertNullableString(run[field], field)
156
+ values.scheduledAt = run.scheduledAt === undefined ? null : assertNullableString(run.scheduledAt, 'scheduledAt')
157
+ return values
158
+ }
159
+
160
+ function normalizePatch(patch) {
161
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new TypeError('patch must be an object')
162
+ const entries = []
163
+ for (const [field, value] of Object.entries(patch)) {
164
+ if (IMMUTABLE_UPDATE_FIELDS.has(field)) throw new TypeError(`${field} cannot be updated`)
165
+ if (!UPDATE_FIELDS.has(field)) throw new TypeError(`Unknown scheduled task run patch field: ${field}`)
166
+ if (value === undefined) continue
167
+ if (field === 'status') entries.push(['status', assertStatus(value)])
168
+ else if (field === 'durationMs') entries.push(['duration_ms', assertDuration(value)])
169
+ else if (field === 'agentSnapshot') entries.push(['agent_snapshot_json', serializeJsonObject(value, 'agentSnapshot')])
170
+ else if (field === 'legacy') entries.push(['legacy_json', serializeJsonObject(value, 'legacy')])
171
+ else entries.push([TEXT_FIELDS[field], assertNullableString(value, field)])
172
+ }
173
+ return entries
174
+ }
175
+
176
+ function parsePositiveInteger(value, fallback, max) {
177
+ const parsed = Number(value)
178
+ if (!Number.isInteger(parsed) || parsed <= 0) return fallback
179
+ return Math.min(parsed, max)
180
+ }
181
+
182
+ function parseListByTaskLimit(value) {
183
+ const parsed = Number(value)
184
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_SCHEDULED_TASK_RUNS_PER_TASK) {
185
+ throw new TypeError(`limit must be an integer between 1 and ${MAX_SCHEDULED_TASK_RUNS_PER_TASK}`)
186
+ }
187
+ return parsed
188
+ }
189
+
190
+ function parsePruneLimit(value) {
191
+ const parsed = Number(value)
192
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > MAX_SCHEDULED_TASK_RUNS_PER_TASK) {
193
+ throw new TypeError(`limit must be an integer between 0 and ${MAX_SCHEDULED_TASK_RUNS_PER_TASK}`)
194
+ }
195
+ return parsed
196
+ }
197
+
198
+ function parseOffset(value) {
199
+ if (value === undefined) return 0
200
+ const parsed = Number(value)
201
+ if (!Number.isInteger(parsed) || parsed < 0) throw new TypeError('offset must be a non-negative integer')
202
+ return parsed
203
+ }
204
+
205
+ function normalizeStringIds(value, field) {
206
+ if (value === undefined || value === null) return undefined
207
+ if (!Array.isArray(value)) throw new TypeError(`${field} must be an array`)
208
+ const result = [...new Set(value.map((id) => assertNonEmptyString(id, field)))]
209
+ return result
210
+ }
211
+
212
+ function escapeLike(value) {
213
+ return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
214
+ }
215
+
216
+ function normalizeFilters(filters = {}) {
217
+ if (!filters || typeof filters !== 'object' || Array.isArray(filters)) throw new TypeError('filters must be an object')
218
+ for (const field of Object.keys(filters)) if (!FILTER_FIELDS.has(field)) throw new TypeError(`Unknown scheduled task run filter: ${field}`)
219
+ const normalized = {
220
+ page: parsePositiveInteger(filters.page, 1, 100_000),
221
+ pageSize: parsePositiveInteger(filters.pageSize, 10, 100),
222
+ }
223
+ if (filters.taskId !== undefined && filters.taskId !== null && filters.taskId !== '') normalized.taskId = assertNonEmptyString(filters.taskId, 'taskId')
224
+ normalized.taskIds = normalizeStringIds(filters.taskIds, 'taskIds')
225
+ normalized.keywordTaskIds = normalizeStringIds(filters.keywordTaskIds, 'keywordTaskIds')
226
+ if (filters.status !== undefined && filters.status !== null && filters.status !== '') normalized.status = assertStatus(filters.status)
227
+ if (filters.trigger !== undefined && filters.trigger !== null && filters.trigger !== '') normalized.trigger = assertTrigger(filters.trigger, { nullable: false })
228
+ if (filters.startedFrom !== undefined && filters.startedFrom !== null && filters.startedFrom !== '') normalized.startedFrom = assertNonEmptyString(filters.startedFrom, 'startedFrom')
229
+ if (filters.startedTo !== undefined && filters.startedTo !== null && filters.startedTo !== '') normalized.startedTo = assertNonEmptyString(filters.startedTo, 'startedTo')
230
+ if (filters.keyword !== undefined && filters.keyword !== null) {
231
+ if (typeof filters.keyword !== 'string') throw new TypeError('keyword must be a string')
232
+ const keyword = filters.keyword.trim()
233
+ if (keyword) normalized.keyword = keyword
234
+ }
235
+ return normalized
236
+ }
237
+
238
+ function inClause(column, values, clauses, parameters) {
239
+ if (values === undefined) return
240
+ if (values.length === 0) {
241
+ clauses.push('0 = 1')
242
+ return
243
+ }
244
+ clauses.push(`${column} IN (${values.map(() => '?').join(', ')})`)
245
+ parameters.push(...values)
246
+ }
247
+
248
+ function buildWhere(filters) {
249
+ const clauses = []
250
+ const parameters = []
251
+ if (filters.taskId) {
252
+ clauses.push('task_id = ?')
253
+ parameters.push(filters.taskId)
254
+ }
255
+ inClause('task_id', filters.taskIds, clauses, parameters)
256
+ if (filters.status) {
257
+ clauses.push('status = ?')
258
+ parameters.push(filters.status)
259
+ }
260
+ if (filters.trigger) {
261
+ clauses.push('trigger = ?')
262
+ parameters.push(filters.trigger)
263
+ }
264
+ if (filters.startedFrom) {
265
+ clauses.push('started_at >= ?')
266
+ parameters.push(filters.startedFrom)
267
+ }
268
+ if (filters.startedTo) {
269
+ clauses.push('started_at <= ?')
270
+ parameters.push(filters.startedTo)
271
+ }
272
+ if (filters.keyword) {
273
+ const pattern = `%${escapeLike(filters.keyword)}%`
274
+ const keywordClauses = [
275
+ "input_content LIKE ? ESCAPE '\\' COLLATE NOCASE",
276
+ "ai_result LIKE ? ESCAPE '\\' COLLATE NOCASE",
277
+ "result LIKE ? ESCAPE '\\' COLLATE NOCASE",
278
+ "error_message LIKE ? ESCAPE '\\' COLLATE NOCASE",
279
+ ]
280
+ const keywordParameters = [pattern, pattern, pattern, pattern]
281
+ if (filters.keywordTaskIds?.length) {
282
+ keywordClauses.unshift(`task_id IN (${filters.keywordTaskIds.map(() => '?').join(', ')})`)
283
+ keywordParameters.unshift(...filters.keywordTaskIds)
284
+ }
285
+ clauses.push(`(${keywordClauses.join(' OR ')})`)
286
+ parameters.push(...keywordParameters)
287
+ }
288
+ return { sql: clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : '', parameters }
289
+ }
290
+
291
+ const INSERT_COLUMNS = `
292
+ task_id, id, status, trigger, input_content, ai_result, result, error_message,
293
+ warning, session_id, scheduled_at, started_at, finished_at, duration_ms,
294
+ agent_id, agent_label, agent_snapshot_json, extra_json, legacy_json, source, updated_at
295
+ `
296
+ const INSERT_PLACEHOLDERS = new Array(21).fill('?').join(', ')
297
+
298
+ function insertParameters(value) {
299
+ return [
300
+ value.taskId, value.id, value.status, value.trigger, value.inputContent, value.aiResult,
301
+ value.result, value.errorMessage, value.warning, value.sessionId, value.scheduledAt,
302
+ value.startedAt, value.finishedAt, value.durationMs, value.agentId, value.agentLabel,
303
+ value.agentSnapshotJson, value.extraJson, value.legacyJson, value.source, value.updatedAt,
304
+ ]
305
+ }
306
+
307
+ export function createScheduledTaskRunsRepository(storageHandle, options = {}) {
308
+ const storage = assertStorage(storageHandle ?? getSqliteStorage())
309
+ const now = typeof options.now === 'function' ? options.now : () => new Date().toISOString()
310
+
311
+ function getRow(taskId, runId, database = storage) {
312
+ assertNonEmptyString(taskId, 'taskId')
313
+ assertNonEmptyString(runId, 'runId')
314
+ return database.prepare('SELECT * FROM scheduled_task_runs WHERE task_id = ? AND id = ?').get(taskId, runId)
315
+ }
316
+
317
+ function insert(database, value) {
318
+ database.prepare(`INSERT INTO scheduled_task_runs (${INSERT_COLUMNS}) VALUES (${INSERT_PLACEHOLDERS})`).run(...insertParameters(value))
319
+ }
320
+
321
+ function upsert(database, value) {
322
+ database.prepare(`
323
+ INSERT INTO scheduled_task_runs (${INSERT_COLUMNS}) VALUES (${INSERT_PLACEHOLDERS})
324
+ ON CONFLICT(task_id, id) DO UPDATE SET
325
+ status = excluded.status,
326
+ trigger = excluded.trigger,
327
+ input_content = excluded.input_content,
328
+ ai_result = excluded.ai_result,
329
+ result = excluded.result,
330
+ error_message = excluded.error_message,
331
+ warning = excluded.warning,
332
+ session_id = excluded.session_id,
333
+ scheduled_at = excluded.scheduled_at,
334
+ started_at = excluded.started_at,
335
+ finished_at = excluded.finished_at,
336
+ duration_ms = excluded.duration_ms,
337
+ agent_id = excluded.agent_id,
338
+ agent_label = excluded.agent_label,
339
+ agent_snapshot_json = excluded.agent_snapshot_json,
340
+ extra_json = excluded.extra_json,
341
+ legacy_json = excluded.legacy_json,
342
+ source = excluded.source,
343
+ updated_at = excluded.updated_at
344
+ `).run(...insertParameters(value))
345
+ }
346
+
347
+ function pruneByTask(taskId, limit = MAX_SCHEDULED_TASK_RUNS_PER_TASK, database = storage) {
348
+ assertNonEmptyString(taskId, 'taskId')
349
+ const normalizedLimit = parsePruneLimit(limit)
350
+ return Number(database.prepare(`
351
+ DELETE FROM scheduled_task_runs
352
+ WHERE task_id = ? AND id IN (
353
+ SELECT id FROM scheduled_task_runs
354
+ WHERE task_id = ?
355
+ ORDER BY started_at DESC, id DESC
356
+ LIMIT -1 OFFSET ?
357
+ )
358
+ `).run(taskId, taskId, normalizedLimit).changes)
359
+ }
360
+
361
+ return Object.freeze({
362
+ create(taskId, run, createOptions = {}) {
363
+ const value = normalizeFullRun(taskId, run, { source: createOptions.source, now })
364
+ return storage.transaction((database) => {
365
+ insert(database, value)
366
+ const inserted = getRow(value.taskId, value.id, database)
367
+ pruneByTask(value.taskId, MAX_SCHEDULED_TASK_RUNS_PER_TASK, database)
368
+ return mapRow(inserted)
369
+ })
370
+ },
371
+
372
+ upsert(taskId, run, upsertOptions = {}) {
373
+ const value = normalizeFullRun(taskId, run, { source: upsertOptions.source, now })
374
+ return storage.transaction((database) => {
375
+ upsert(database, value)
376
+ pruneByTask(value.taskId, MAX_SCHEDULED_TASK_RUNS_PER_TASK, database)
377
+ return mapRow(getRow(value.taskId, value.id, database))
378
+ })
379
+ },
380
+
381
+ replaceAll(entries, replaceOptions = {}) {
382
+ if (!Array.isArray(entries)) throw new TypeError('entries must be an array')
383
+ const values = entries.map((entry) => {
384
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new TypeError('entry must be an object')
385
+ return normalizeFullRun(entry.taskId, entry.run, { source: entry.source ?? replaceOptions.source, now })
386
+ })
387
+ const seen = new Set()
388
+ for (const value of values) {
389
+ const key = JSON.stringify([value.taskId, value.id])
390
+ if (seen.has(key)) throw new TypeError(`Duplicate scheduled task run: ${value.taskId}/${value.id}`)
391
+ seen.add(key)
392
+ }
393
+ return storage.transaction((database) => {
394
+ database.prepare('DELETE FROM scheduled_task_runs').run()
395
+ for (const value of values) insert(database, value)
396
+ return values.length
397
+ })
398
+ },
399
+
400
+ update(taskId, runId, patch) {
401
+ assertNonEmptyString(taskId, 'taskId')
402
+ assertNonEmptyString(runId, 'runId')
403
+ const entries = normalizePatch(patch)
404
+ if (entries.length === 0) return mapRow(getRow(taskId, runId))
405
+ entries.push(['updated_at', now()])
406
+ const assignments = entries.map(([column]) => `${column} = ?`).join(', ')
407
+ const values = entries.map(([, value]) => value)
408
+ const result = storage.prepare(`UPDATE scheduled_task_runs SET ${assignments} WHERE task_id = ? AND id = ?`).run(...values, taskId, runId)
409
+ return Number(result.changes) === 0 ? null : mapRow(getRow(taskId, runId))
410
+ },
411
+
412
+ get(taskId, runId) {
413
+ return mapRow(getRow(taskId, runId))
414
+ },
415
+
416
+ listByTask(taskId, { limit = MAX_SCHEDULED_TASK_RUNS_PER_TASK, offset = 0 } = {}) {
417
+ assertNonEmptyString(taskId, 'taskId')
418
+ const normalizedLimit = parseListByTaskLimit(limit)
419
+ const normalizedOffset = parseOffset(offset)
420
+ return storage.prepare(`
421
+ SELECT * FROM scheduled_task_runs
422
+ WHERE task_id = ?
423
+ ORDER BY started_at DESC, id DESC
424
+ LIMIT ? OFFSET ?
425
+ `).all(taskId, normalizedLimit, normalizedOffset).map((row) => mapRow(row))
426
+ },
427
+
428
+ list(filters = {}) {
429
+ const normalized = normalizeFilters(filters)
430
+ const where = buildWhere(normalized)
431
+ const offset = (normalized.page - 1) * normalized.pageSize
432
+ return storage.transaction((database) => {
433
+ const total = Number(database.prepare(`SELECT COUNT(*) AS count FROM scheduled_task_runs${where.sql}`).get(...where.parameters).count)
434
+ const runs = database.prepare(`
435
+ SELECT * FROM scheduled_task_runs${where.sql}
436
+ ORDER BY started_at DESC, id DESC, task_id DESC
437
+ LIMIT ? OFFSET ?
438
+ `).all(...where.parameters, normalized.pageSize, offset).map((row) => mapRow(row, { includeTaskId: true }))
439
+ return { runs, total, page: normalized.page, pageSize: normalized.pageSize }
440
+ }, { mode: 'deferred' })
441
+ },
442
+
443
+ count(filters = {}) {
444
+ const normalized = normalizeFilters(filters)
445
+ const where = buildWhere(normalized)
446
+ return Number(storage.prepare(`SELECT COUNT(*) AS count FROM scheduled_task_runs${where.sql}`).get(...where.parameters).count)
447
+ },
448
+
449
+ delete(taskId, runId) {
450
+ assertNonEmptyString(taskId, 'taskId')
451
+ assertNonEmptyString(runId, 'runId')
452
+ return Number(storage.prepare('DELETE FROM scheduled_task_runs WHERE task_id = ? AND id = ?').run(taskId, runId).changes) > 0
453
+ },
454
+
455
+ deleteByTask(taskId) {
456
+ assertNonEmptyString(taskId, 'taskId')
457
+ return Number(storage.prepare('DELETE FROM scheduled_task_runs WHERE task_id = ?').run(taskId).changes)
458
+ },
459
+
460
+ prune(taskId, limit = MAX_SCHEDULED_TASK_RUNS_PER_TASK) {
461
+ return storage.transaction((database) => pruneByTask(taskId, limit, database))
462
+ },
463
+ })
464
+ }