@gentbajko/slopify 0.8.3 → 1.0.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/README.md +28 -6
- package/SUBTITLES.md +2 -0
- package/dist/adapters/alignment/window.js +46 -5
- package/dist/adapters/alignment/worker.js +2 -2
- package/dist/adapters/llm/claude-code.js +6 -3
- package/dist/adapters/llm/codex.js +102 -12
- package/dist/adapters/llm/gemini.js +11 -8
- package/dist/adapters/llm/run-cli.js +61 -6
- package/dist/adapters/tts/inworld-async.js +4 -4
- package/dist/adapters/tts/inworld.js +73 -68
- package/dist/catalog/registry.js +13 -73
- package/dist/edge/cli.js +5 -2
- package/dist/edge/events/hub.js +7 -2
- package/dist/edge/events/preview-cache.js +7 -4
- package/dist/edge/events/visibility.js +33 -0
- package/dist/edge/http/actions.js +74 -79
- package/dist/edge/http/app.js +39 -6
- package/dist/edge/http/audio-preview.js +9 -1
- package/dist/edge/http/checkpoints.js +95 -0
- package/dist/edge/http/diagnostics.js +31 -0
- package/dist/edge/http/draft-files.js +69 -0
- package/dist/edge/http/draft-problem.js +15 -0
- package/dist/edge/http/drafts.js +95 -0
- package/dist/edge/http/multipart.js +86 -0
- package/dist/edge/http/mutations.js +49 -0
- package/dist/edge/http/planning.js +12 -1
- package/dist/edge/http/project-templates.js +74 -0
- package/dist/edge/http/projects.js +19 -2
- package/dist/edge/http/revision-files.js +84 -0
- package/dist/edge/http/revisions.js +103 -0
- package/dist/edge/http/schedules.js +100 -0
- package/dist/edge/http/staging.js +11 -52
- package/dist/edge/http/storage.js +110 -0
- package/dist/edge/http/subtitles.js +9 -79
- package/dist/edge/http/tutorial.js +26 -0
- package/dist/edge/signal-shutdown.js +23 -0
- package/dist/kernel/audio-preview.js +11 -5
- package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
- package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
- package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
- package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
- package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
- package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
- package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
- package/dist/kernel/ports/model.js +5 -0
- package/dist/kernel/runner/attempt-repo.js +15 -0
- package/dist/kernel/runner/attempt.js +14 -2
- package/dist/kernel/runner/checkpoint-authority.js +23 -0
- package/dist/kernel/runner/index.js +35 -11
- package/dist/kernel/runner/providers.js +18 -8
- package/dist/kernel/runner/work-attempt.js +47 -0
- package/dist/kernel/runner/work-authority.js +37 -0
- package/dist/kernel/runner/work.js +23 -0
- package/dist/main.js +227 -69
- package/dist/slices/admission/repo.js +5 -56
- package/dist/slices/admission/schema.js +64 -0
- package/dist/slices/admission/start.js +13 -4
- package/dist/slices/article/continuation.js +9 -3
- package/dist/slices/article/provided-entries.js +7 -3
- package/dist/slices/article/run.js +20 -7
- package/dist/slices/article/segments.js +13 -4
- package/dist/slices/batch/index.js +8 -11
- package/dist/slices/cancel/index.js +78 -34
- package/dist/slices/checkpoints/change.js +250 -0
- package/dist/slices/checkpoints/fingerprint.js +33 -0
- package/dist/slices/checkpoints/index.js +2 -0
- package/dist/slices/checkpoints/model.js +1 -0
- package/dist/slices/checkpoints/recovery.js +116 -0
- package/dist/slices/checkpoints/repo.js +132 -0
- package/dist/slices/checkpoints/rules.js +30 -0
- package/dist/slices/checkpoints/schema.js +59 -0
- package/dist/slices/control/index.js +88 -22
- package/dist/slices/control/providers.js +3 -2
- package/dist/slices/control/revision-control-schema.js +7 -0
- package/dist/slices/control/revision-control.js +59 -0
- package/dist/slices/estimate/index.js +77 -59
- package/dist/slices/estimate/requests.js +102 -0
- package/dist/slices/images/run.js +14 -3
- package/dist/slices/library/slots.js +11 -11
- package/dist/slices/library/snapshot.js +33 -0
- package/dist/slices/narration/legacy-plan.js +58 -0
- package/dist/slices/narration/live.js +2 -2
- package/dist/slices/narration/plan.js +48 -54
- package/dist/slices/narration/run.js +21 -8
- package/dist/slices/play-drafts/convert.js +120 -0
- package/dist/slices/play-drafts/model.js +1 -0
- package/dist/slices/play-drafts/readiness.js +80 -0
- package/dist/slices/play-drafts/repo.js +76 -0
- package/dist/slices/play-drafts/review-inputs.js +150 -0
- package/dist/slices/play-drafts/review.js +109 -0
- package/dist/slices/play-drafts/schema.js +218 -0
- package/dist/slices/play-drafts/service.js +242 -0
- package/dist/slices/play-drafts/start-repo.js +113 -0
- package/dist/slices/play-drafts/start.js +148 -0
- package/dist/slices/play-drafts/uploads.js +67 -0
- package/dist/slices/project-templates/from-project.js +159 -0
- package/dist/slices/project-templates/model.js +1 -0
- package/dist/slices/project-templates/repo.js +41 -0
- package/dist/slices/project-templates/schema.js +37 -0
- package/dist/slices/project-templates/service.js +136 -0
- package/dist/slices/project-templates/setup.js +55 -0
- package/dist/slices/rebuild/admission-repo.js +120 -0
- package/dist/slices/rebuild/dependencies.js +33 -0
- package/dist/slices/rebuild/model.js +94 -0
- package/dist/slices/rebuild/narration-history.js +43 -0
- package/dist/slices/rebuild/narration-reuse.js +20 -0
- package/dist/slices/rebuild/preview-details.js +144 -0
- package/dist/slices/rebuild/preview-plan.js +223 -0
- package/dist/slices/rebuild/preview-retained.js +128 -0
- package/dist/slices/rebuild/provided-review.js +32 -0
- package/dist/slices/rebuild/recipe-audio.js +172 -0
- package/dist/slices/rebuild/recipe-build.js +17 -0
- package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
- package/dist/slices/rebuild/recipe-exports.js +65 -0
- package/dist/slices/rebuild/recipe-fixture.js +150 -0
- package/dist/slices/rebuild/recipe-input-schema.js +82 -0
- package/dist/slices/rebuild/recipe-legacy.js +135 -0
- package/dist/slices/rebuild/recipe-model.js +77 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
- package/dist/slices/rebuild/recipe-save.js +221 -0
- package/dist/slices/rebuild/recipe-text.js +170 -0
- package/dist/slices/rebuild/recipe-validation.js +86 -0
- package/dist/slices/rebuild/recipe-visual.js +111 -0
- package/dist/slices/rebuild/recipe-work.js +212 -0
- package/dist/slices/rebuild/recipes.js +4 -0
- package/dist/slices/rebuild/repo.js +64 -0
- package/dist/slices/rebuild/runtime-actions.js +76 -0
- package/dist/slices/rebuild/runtime-admission.js +71 -0
- package/dist/slices/rebuild/runtime-article.js +117 -0
- package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
- package/dist/slices/rebuild/runtime-export.js +139 -0
- package/dist/slices/rebuild/runtime-instructions.js +51 -0
- package/dist/slices/rebuild/runtime-local.js +137 -0
- package/dist/slices/rebuild/runtime-materialize.js +105 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
- package/dist/slices/rebuild/runtime-plan.js +103 -0
- package/dist/slices/rebuild/runtime-provider.js +184 -0
- package/dist/slices/rebuild/runtime-publication.js +172 -0
- package/dist/slices/rebuild/runtime-run.js +35 -0
- package/dist/slices/rebuild/runtime-store.js +166 -0
- package/dist/slices/rebuild/runtime-subtitles.js +202 -0
- package/dist/slices/rebuild/service-readiness.js +110 -0
- package/dist/slices/rebuild/service.js +149 -0
- package/dist/slices/rebuild/transition-repo.js +108 -0
- package/dist/slices/rebuild/work-records.js +44 -0
- package/dist/slices/reruns/index.js +5 -0
- package/dist/slices/research/run.js +33 -12
- package/dist/slices/revisions/adopt-assets.js +31 -0
- package/dist/slices/revisions/adopt-content.js +112 -0
- package/dist/slices/revisions/adopt.js +100 -0
- package/dist/slices/revisions/downloads.js +80 -0
- package/dist/slices/revisions/index.js +5 -0
- package/dist/slices/revisions/manifest-repo.js +125 -0
- package/dist/slices/revisions/model.js +1 -0
- package/dist/slices/revisions/mutation-assets.js +143 -0
- package/dist/slices/revisions/mutation-cues.js +95 -0
- package/dist/slices/revisions/mutation-prepare.js +182 -0
- package/dist/slices/revisions/mutation-request.js +58 -0
- package/dist/slices/revisions/mutation-work.js +47 -0
- package/dist/slices/revisions/mutations.js +194 -0
- package/dist/slices/revisions/projection.js +82 -0
- package/dist/slices/revisions/publication-model.js +1 -0
- package/dist/slices/revisions/publication-rules.js +166 -0
- package/dist/slices/revisions/publish.js +115 -0
- package/dist/slices/revisions/repo.js +83 -0
- package/dist/slices/revisions/restore.js +60 -0
- package/dist/slices/revisions/rules.js +99 -0
- package/dist/slices/revisions/schema.js +160 -0
- package/dist/slices/revisions/view.js +44 -0
- package/dist/slices/schedules/calendar.js +47 -0
- package/dist/slices/schedules/model.js +1 -0
- package/dist/slices/schedules/repo.js +281 -0
- package/dist/slices/schedules/scheduler.js +144 -0
- package/dist/slices/schedules/schema.js +80 -0
- package/dist/slices/schedules/service.js +178 -0
- package/dist/slices/settings/cli-paths.js +1 -1
- package/dist/slices/settings/cli-status.js +45 -5
- package/dist/slices/settings/tutorial-schema.js +39 -0
- package/dist/slices/settings/tutorial.js +66 -0
- package/dist/slices/storage/assets.js +108 -0
- package/dist/slices/storage/delete-project.js +2 -1
- package/dist/slices/storage/downloads.js +4 -1
- package/dist/slices/storage/portable.js +871 -0
- package/dist/slices/storage/prepare.js +67 -0
- package/dist/slices/storage/reconcile.js +25 -5
- package/dist/slices/storage/repo.js +2 -15
- package/dist/slices/storage/schema.js +35 -0
- package/dist/slices/storage/staging-refs.js +26 -0
- package/dist/slices/storage/staging.js +16 -11
- package/dist/slices/thumbnail/run.js +26 -9
- package/dist/slices/video/audio-export-args.js +33 -0
- package/dist/slices/video/audio-export.js +13 -36
- package/dist/slices/video/audio-inputs.js +4 -3
- package/dist/slices/video/ffmpeg.js +6 -0
- package/dist/slices/video/reuse-audio.js +11 -4
- package/dist/updater/install-flow.js +27 -2
- package/dist/updater/plan.js +28 -2
- package/dist/updater/service.js +27 -2
- package/dist/updater/worker.js +2 -1
- package/dist/web/assets/index-CO7DnVX9.js +137 -0
- package/dist/web/assets/index-DX1_chKs.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/adapters/tts/inworld-text.js +0 -32
- package/dist/web/assets/index-Ce2HApus.css +0 -1
- package/dist/web/assets/index-xbD0MZOY.js +0 -130
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { derive } from "../../kernel/runner/graph.js";
|
|
4
|
+
import { projectPaused, stagesOf } from "../admission/repo.js";
|
|
5
|
+
import { readStartReceipt } from "../play-drafts/start-repo.js";
|
|
6
|
+
import { scheduleRunSchema, scheduleSummarySchema, } from "./schema.js";
|
|
7
|
+
const rowSchema = z.object({
|
|
8
|
+
id: z.string(),
|
|
9
|
+
name: z.string(),
|
|
10
|
+
template_id: z.string(),
|
|
11
|
+
template_version: z.number(),
|
|
12
|
+
cadence_json: z.string(),
|
|
13
|
+
timezone: z.string(),
|
|
14
|
+
missed_policy: z.string(),
|
|
15
|
+
overlap_policy: z.string(),
|
|
16
|
+
spend_limit_cents: z.number().nullable(),
|
|
17
|
+
items_json: z.string(),
|
|
18
|
+
status: z.string(),
|
|
19
|
+
version: z.number(),
|
|
20
|
+
next_run_at: z.string().nullable(),
|
|
21
|
+
created_at: z.string(),
|
|
22
|
+
updated_at: z.string(),
|
|
23
|
+
deleted_at: z.string().nullable(),
|
|
24
|
+
});
|
|
25
|
+
export function scheduleById(db, id) {
|
|
26
|
+
const row = db
|
|
27
|
+
.prepare(`SELECT id,name,template_id,template_version,cadence_json,timezone,missed_policy,
|
|
28
|
+
overlap_policy,spend_limit_cents,items_json,status,version,next_run_at,created_at,updated_at,
|
|
29
|
+
deleted_at
|
|
30
|
+
FROM schedules WHERE id=?`)
|
|
31
|
+
.get(id);
|
|
32
|
+
return row === undefined ? undefined : parseSchedule(row);
|
|
33
|
+
}
|
|
34
|
+
export function scheduleRows(db) {
|
|
35
|
+
return db
|
|
36
|
+
.prepare(`SELECT id,name,template_id,template_version,cadence_json,timezone,missed_policy,
|
|
37
|
+
overlap_policy,spend_limit_cents,items_json,status,version,next_run_at,created_at,updated_at,
|
|
38
|
+
deleted_at
|
|
39
|
+
FROM schedules ORDER BY created_at DESC,id`)
|
|
40
|
+
.all()
|
|
41
|
+
.map(parseSchedule);
|
|
42
|
+
}
|
|
43
|
+
export function dueSchedules(db, now) {
|
|
44
|
+
return db
|
|
45
|
+
.prepare(`SELECT id,name,template_id,template_version,cadence_json,timezone,missed_policy,
|
|
46
|
+
overlap_policy,spend_limit_cents,items_json,status,version,next_run_at,created_at,updated_at,
|
|
47
|
+
deleted_at
|
|
48
|
+
FROM schedules WHERE deleted_at IS NULL AND status='active' AND next_run_at IS NOT NULL
|
|
49
|
+
AND next_run_at <= ?
|
|
50
|
+
ORDER BY next_run_at,id`)
|
|
51
|
+
.all(now)
|
|
52
|
+
.map(parseSchedule);
|
|
53
|
+
}
|
|
54
|
+
export function insertSchedule(db, schedule, creationHash) {
|
|
55
|
+
db.prepare(`INSERT INTO schedules
|
|
56
|
+
(id,name,template_id,template_version,cadence_json,timezone,missed_policy,overlap_policy,
|
|
57
|
+
spend_limit_cents,items_json,status,version,creation_hash,next_run_at,created_at,updated_at,
|
|
58
|
+
deleted_at)
|
|
59
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(schedule.id, schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.status, schedule.version, creationHash, schedule.nextRunAt, schedule.createdAt, schedule.updatedAt, schedule.deletedAt);
|
|
60
|
+
}
|
|
61
|
+
export function updateScheduleRow(db, schedule, mutationId, mutationHash, baseVersion) {
|
|
62
|
+
const changed = db
|
|
63
|
+
.prepare(`UPDATE schedules SET name=?,template_id=?,template_version=?,cadence_json=?,timezone=?,
|
|
64
|
+
missed_policy=?,overlap_policy=?,spend_limit_cents=?,items_json=?,status=?,version=?,
|
|
65
|
+
next_run_at=?,updated_at=?,mutation_id=?,mutation_hash=?
|
|
66
|
+
WHERE id=? AND version=? AND deleted_at IS NULL`)
|
|
67
|
+
.run(schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.status, schedule.version, schedule.nextRunAt, schedule.updatedAt, mutationId, mutationHash, schedule.id, baseVersion);
|
|
68
|
+
return Number(changed.changes) === 1;
|
|
69
|
+
}
|
|
70
|
+
export function tombstoneScheduleRow(db, id, version, deletedAt) {
|
|
71
|
+
const changed = db
|
|
72
|
+
.prepare(`UPDATE schedules SET deleted_at=?,next_run_at=NULL,version=version+1,updated_at=?
|
|
73
|
+
WHERE id=? AND version=? AND deleted_at IS NULL`)
|
|
74
|
+
.run(deletedAt, deletedAt, id, version);
|
|
75
|
+
return Number(changed.changes) === 1;
|
|
76
|
+
}
|
|
77
|
+
export function setScheduleStatus(db, id, version, status, nextRunAt, updatedAt) {
|
|
78
|
+
const changed = db
|
|
79
|
+
.prepare(`UPDATE schedules SET status=?,next_run_at=?,version=version+1,updated_at=?
|
|
80
|
+
WHERE id=? AND version=? AND deleted_at IS NULL`)
|
|
81
|
+
.run(status, nextRunAt, updatedAt, id, version);
|
|
82
|
+
return Number(changed.changes) === 1;
|
|
83
|
+
}
|
|
84
|
+
export function insertRun(db, run) {
|
|
85
|
+
try {
|
|
86
|
+
db.prepare(`INSERT INTO schedule_runs
|
|
87
|
+
(id,schedule_id,scheduled_for,status,request_id,project_ids_json,estimate_json,started_at,ended_at,error)
|
|
88
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)`).run(run.id, run.scheduleId, run.scheduledFor, run.status, run.requestId, JSON.stringify(run.projectIds), run.estimate === null ? null : JSON.stringify(run.estimate), run.startedAt, run.endedAt, run.error);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error instanceof Error && error.message.includes("UNIQUE"))
|
|
93
|
+
return false;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function updateRun(db, run) {
|
|
98
|
+
db.prepare(`UPDATE schedule_runs SET status=?,request_id=?,project_ids_json=?,estimate_json=?,
|
|
99
|
+
ended_at=?,error=? WHERE id=?`).run(run.status, run.requestId, JSON.stringify(run.projectIds), run.estimate === null ? null : JSON.stringify(run.estimate), run.endedAt, run.error, run.id);
|
|
100
|
+
}
|
|
101
|
+
export function recordRunStartIdentity(db, run) {
|
|
102
|
+
if (run.requestId === null)
|
|
103
|
+
throw new Error("A scheduled Start requires a request identity.");
|
|
104
|
+
const changed = db
|
|
105
|
+
.prepare(`UPDATE schedule_runs SET request_id=?,estimate_json=?
|
|
106
|
+
WHERE id=? AND status='running' AND request_id IS NULL AND project_ids_json='[]'`)
|
|
107
|
+
.run(run.requestId, run.estimate === null ? null : JSON.stringify(run.estimate), run.id);
|
|
108
|
+
if (Number(changed.changes) !== 1)
|
|
109
|
+
throw new Error("The scheduled run could not persist its Start identity.");
|
|
110
|
+
}
|
|
111
|
+
export function recordRunDispatch(db, run, recordedAt) {
|
|
112
|
+
transact(db, () => {
|
|
113
|
+
const changed = db
|
|
114
|
+
.prepare(`UPDATE schedule_runs SET request_id=?,project_ids_json=?,estimate_json=?
|
|
115
|
+
WHERE id=? AND status='running'`)
|
|
116
|
+
.run(run.requestId, JSON.stringify(run.projectIds), run.estimate === null ? null : JSON.stringify(run.estimate), run.id);
|
|
117
|
+
if (Number(changed.changes) !== 1)
|
|
118
|
+
throw new Error("The scheduled run could not record its admitted projects.");
|
|
119
|
+
for (const projectId of run.projectIds)
|
|
120
|
+
settleScheduleRunsForProject(db, projectId, recordedAt);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
export function runsForSchedule(db, scheduleId) {
|
|
124
|
+
return db
|
|
125
|
+
.prepare(`SELECT id,schedule_id,scheduled_for,status,request_id,project_ids_json,estimate_json,
|
|
126
|
+
started_at,ended_at,error FROM schedule_runs WHERE schedule_id=? ORDER BY started_at DESC,id DESC`)
|
|
127
|
+
.all(scheduleId)
|
|
128
|
+
.map(parseRun);
|
|
129
|
+
}
|
|
130
|
+
export function activeRun(db, scheduleId, settledAt) {
|
|
131
|
+
return transact(db, () => {
|
|
132
|
+
if (db
|
|
133
|
+
.prepare("SELECT 1 FROM schedule_runs WHERE schedule_id=? AND status='running' LIMIT 1")
|
|
134
|
+
.get(scheduleId) !== undefined)
|
|
135
|
+
return true;
|
|
136
|
+
const occurrences = db
|
|
137
|
+
.prepare(`SELECT id,project_ids_json FROM schedule_runs
|
|
138
|
+
WHERE schedule_id=? AND projects_settled_at IS NULL
|
|
139
|
+
AND json_array_length(project_ids_json)>0
|
|
140
|
+
ORDER BY scheduled_for,started_at,id`)
|
|
141
|
+
.all(scheduleId)
|
|
142
|
+
.map((row) => z.object({ id: z.string(), project_ids_json: z.string() }).parse(row));
|
|
143
|
+
let anyActive = false;
|
|
144
|
+
for (const occurrence of occurrences) {
|
|
145
|
+
const ids = z.array(z.string()).parse(JSON.parse(occurrence.project_ids_json));
|
|
146
|
+
if (ids.some((id) => projectIsActive(db, id))) {
|
|
147
|
+
anyActive = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
db.prepare("UPDATE schedule_runs SET projects_settled_at=? WHERE id=? AND projects_settled_at IS NULL").run(settledAt, occurrence.id);
|
|
151
|
+
}
|
|
152
|
+
return anyActive;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Permanently settles every scheduled occurrence containing `projectId` once all projects
|
|
157
|
+
* admitted by that occurrence are terminal. The runner calls this in the same transaction as
|
|
158
|
+
* its terminal work write, so a later edit creates a new revision without reopening history.
|
|
159
|
+
*/
|
|
160
|
+
export function settleScheduleRunsForProject(db, projectId, settledAt) {
|
|
161
|
+
return transact(db, () => {
|
|
162
|
+
const occurrences = db
|
|
163
|
+
.prepare(`SELECT DISTINCT schedule_runs.id, schedule_runs.project_ids_json
|
|
164
|
+
FROM schedule_runs, json_each(schedule_runs.project_ids_json) AS admitted
|
|
165
|
+
WHERE schedule_runs.projects_settled_at IS NULL AND admitted.value=?`)
|
|
166
|
+
.all(projectId)
|
|
167
|
+
.map((row) => z.object({ id: z.string(), project_ids_json: z.string() }).parse(row));
|
|
168
|
+
let settled = 0;
|
|
169
|
+
for (const occurrence of occurrences) {
|
|
170
|
+
const ids = z.array(z.string()).parse(JSON.parse(occurrence.project_ids_json));
|
|
171
|
+
if (ids.some((id) => projectIsActive(db, id)))
|
|
172
|
+
continue;
|
|
173
|
+
const changed = db
|
|
174
|
+
.prepare("UPDATE schedule_runs SET projects_settled_at=? WHERE id=? AND projects_settled_at IS NULL")
|
|
175
|
+
.run(settledAt, occurrence.id);
|
|
176
|
+
settled += Number(changed.changes);
|
|
177
|
+
}
|
|
178
|
+
return settled;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** Freeze every terminal admitted occurrence during boot/migration recovery before the UI can
|
|
182
|
+
* create a new revision on one of its projects. */
|
|
183
|
+
export function settleTerminalScheduleRuns(db, settledAt) {
|
|
184
|
+
return transact(db, () => {
|
|
185
|
+
const projectIds = db
|
|
186
|
+
.prepare(`SELECT DISTINCT admitted.value AS project_id
|
|
187
|
+
FROM schedule_runs, json_each(schedule_runs.project_ids_json) AS admitted
|
|
188
|
+
WHERE schedule_runs.projects_settled_at IS NULL`)
|
|
189
|
+
.all()
|
|
190
|
+
.map((row) => z.object({ project_id: z.string() }).parse(row).project_id);
|
|
191
|
+
let settled = 0;
|
|
192
|
+
for (const projectId of projectIds)
|
|
193
|
+
settled += settleScheduleRunsForProject(db, projectId, settledAt);
|
|
194
|
+
return settled;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function projectIsActive(db, projectId) {
|
|
198
|
+
if (db.prepare("SELECT 1 FROM projects WHERE id=?").get(projectId) === undefined)
|
|
199
|
+
return false;
|
|
200
|
+
const state = derive(stagesOf(db, projectId), projectPaused(db, projectId));
|
|
201
|
+
return state !== "done" && state !== "failed" && state !== "canceled";
|
|
202
|
+
}
|
|
203
|
+
export function recoverRunningRuns(db, endedAt, error = "The app stopped while this scheduled run was active.") {
|
|
204
|
+
return transact(db, () => {
|
|
205
|
+
const pending = db
|
|
206
|
+
.prepare(`SELECT id,request_id FROM schedule_runs
|
|
207
|
+
WHERE status='running' AND request_id IS NOT NULL AND project_ids_json='[]'`)
|
|
208
|
+
.all()
|
|
209
|
+
.map((row) => z.object({ id: z.string(), request_id: z.string() }).parse(row));
|
|
210
|
+
for (const row of pending) {
|
|
211
|
+
const receipt = readStartReceipt(db, row.request_id);
|
|
212
|
+
if (receipt === undefined || receipt.result.requestId !== row.request_id)
|
|
213
|
+
continue;
|
|
214
|
+
db.prepare(`UPDATE schedule_runs SET project_ids_json=?
|
|
215
|
+
WHERE id=? AND status='running' AND request_id=? AND project_ids_json='[]'`).run(JSON.stringify(receipt.result.projectIds), row.id, row.request_id);
|
|
216
|
+
}
|
|
217
|
+
const admitted = db
|
|
218
|
+
.prepare(`SELECT project_ids_json FROM schedule_runs
|
|
219
|
+
WHERE status='running' AND json_array_length(project_ids_json)>0`)
|
|
220
|
+
.all()
|
|
221
|
+
.flatMap((row) => {
|
|
222
|
+
const parsed = z.object({ project_ids_json: z.string() }).parse(row);
|
|
223
|
+
return z.array(z.string()).parse(JSON.parse(parsed.project_ids_json));
|
|
224
|
+
});
|
|
225
|
+
const changed = db
|
|
226
|
+
.prepare("UPDATE schedule_runs SET status='failed',ended_at=?,error=? WHERE status='running'")
|
|
227
|
+
.run(endedAt, error);
|
|
228
|
+
for (const projectId of admitted)
|
|
229
|
+
settleScheduleRunsForProject(db, projectId, endedAt);
|
|
230
|
+
return Number(changed.changes);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
function parseSchedule(row) {
|
|
234
|
+
const value = rowSchema.parse(row);
|
|
235
|
+
return scheduleSummarySchema.parse({
|
|
236
|
+
id: value.id,
|
|
237
|
+
name: value.name,
|
|
238
|
+
templateId: value.template_id,
|
|
239
|
+
templateVersion: value.template_version,
|
|
240
|
+
cadence: JSON.parse(value.cadence_json),
|
|
241
|
+
timezone: value.timezone,
|
|
242
|
+
missedPolicy: value.missed_policy,
|
|
243
|
+
overlapPolicy: value.overlap_policy,
|
|
244
|
+
spendLimitCents: value.spend_limit_cents,
|
|
245
|
+
items: JSON.parse(value.items_json),
|
|
246
|
+
status: value.status,
|
|
247
|
+
version: value.version,
|
|
248
|
+
nextRunAt: value.next_run_at,
|
|
249
|
+
createdAt: value.created_at,
|
|
250
|
+
updatedAt: value.updated_at,
|
|
251
|
+
deletedAt: value.deleted_at,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function parseRun(row) {
|
|
255
|
+
const value = z
|
|
256
|
+
.object({
|
|
257
|
+
id: z.string(),
|
|
258
|
+
schedule_id: z.string(),
|
|
259
|
+
scheduled_for: z.string(),
|
|
260
|
+
status: z.string(),
|
|
261
|
+
request_id: z.string().nullable(),
|
|
262
|
+
project_ids_json: z.string(),
|
|
263
|
+
estimate_json: z.string().nullable(),
|
|
264
|
+
started_at: z.string(),
|
|
265
|
+
ended_at: z.string().nullable(),
|
|
266
|
+
error: z.string().nullable(),
|
|
267
|
+
})
|
|
268
|
+
.parse(row);
|
|
269
|
+
return scheduleRunSchema.parse({
|
|
270
|
+
id: value.id,
|
|
271
|
+
scheduleId: value.schedule_id,
|
|
272
|
+
scheduledFor: value.scheduled_for,
|
|
273
|
+
status: value.status,
|
|
274
|
+
requestId: value.request_id,
|
|
275
|
+
projectIds: JSON.parse(value.project_ids_json),
|
|
276
|
+
estimate: value.estimate_json === null ? null : JSON.parse(value.estimate_json),
|
|
277
|
+
startedAt: value.started_at,
|
|
278
|
+
endedAt: value.ended_at,
|
|
279
|
+
error: value.error,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
2
|
+
import { reviewDraft } from "../play-drafts/review.js";
|
|
3
|
+
import { createDraft } from "../play-drafts/service.js";
|
|
4
|
+
import { startPlayDraft } from "../play-drafts/start.js";
|
|
5
|
+
import { freshTemplateDraft } from "../project-templates/setup.js";
|
|
6
|
+
import { nextOccurrence } from "./calendar.js";
|
|
7
|
+
import { activeRun, dueSchedules, insertRun, recordRunDispatch, recordRunStartIdentity, recoverRunningRuns, scheduleById, updateRun, } from "./repo.js";
|
|
8
|
+
const missedGraceMs = 60_000;
|
|
9
|
+
export function createScheduleRunner(deps) {
|
|
10
|
+
let ticking = false;
|
|
11
|
+
return {
|
|
12
|
+
tick: async (now = deps.clock.now()) => {
|
|
13
|
+
if (ticking)
|
|
14
|
+
return;
|
|
15
|
+
ticking = true;
|
|
16
|
+
try {
|
|
17
|
+
recoverRunningRuns(deps.db, now.toISOString(), "The previous schedule tick could not record this run's result.");
|
|
18
|
+
const due = dueSchedules(deps.db, now.toISOString());
|
|
19
|
+
const claimed = due.flatMap((schedule) => claim(deps, schedule, now));
|
|
20
|
+
const settled = await Promise.allSettled(claimed.map((entry) => execute(deps, entry)));
|
|
21
|
+
const failed = settled.find((result) => result.status === "rejected");
|
|
22
|
+
if (failed)
|
|
23
|
+
throw failed.reason;
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
ticking = false;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
recover: (now = deps.clock.now()) => recoverRunningRuns(deps.db, now.toISOString()),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function claim(deps, schedule, now) {
|
|
33
|
+
if (schedule.nextRunAt === null)
|
|
34
|
+
return [];
|
|
35
|
+
const scheduledFor = new Date(schedule.nextRunAt);
|
|
36
|
+
const occurrence = schedule.missedPolicy === "skip" && now.valueOf() - scheduledFor.valueOf() > missedGraceMs;
|
|
37
|
+
let next = nextOccurrence(schedule.cadence, schedule.timezone, new Date(scheduledFor.valueOf() + 1000));
|
|
38
|
+
while (next !== null && next <= now)
|
|
39
|
+
next = nextOccurrence(schedule.cadence, schedule.timezone, new Date(next.valueOf() + 1000));
|
|
40
|
+
const status = next === null ? "completed" : schedule.status;
|
|
41
|
+
return transact(deps.db, () => {
|
|
42
|
+
const current = scheduleById(deps.db, schedule.id);
|
|
43
|
+
if (current === undefined ||
|
|
44
|
+
current.version !== schedule.version ||
|
|
45
|
+
current.nextRunAt !== schedule.nextRunAt ||
|
|
46
|
+
current.status !== "active")
|
|
47
|
+
return [];
|
|
48
|
+
const overlapping = activeRun(deps.db, schedule.id, now.toISOString());
|
|
49
|
+
const skip = occurrence || overlapping;
|
|
50
|
+
const run = {
|
|
51
|
+
id: deps.uuid(),
|
|
52
|
+
scheduleId: schedule.id,
|
|
53
|
+
scheduledFor: scheduledFor.toISOString(),
|
|
54
|
+
status: skip ? "skipped" : "running",
|
|
55
|
+
requestId: null,
|
|
56
|
+
projectIds: [],
|
|
57
|
+
estimate: null,
|
|
58
|
+
startedAt: now.toISOString(),
|
|
59
|
+
endedAt: skip ? now.toISOString() : null,
|
|
60
|
+
error: skip
|
|
61
|
+
? overlapping
|
|
62
|
+
? "Skipped because another occurrence is still running."
|
|
63
|
+
: "Skipped because the app missed this occurrence."
|
|
64
|
+
: null,
|
|
65
|
+
};
|
|
66
|
+
if (!insertRun(deps.db, run))
|
|
67
|
+
return [];
|
|
68
|
+
deps.db
|
|
69
|
+
.prepare("UPDATE schedules SET status=?,next_run_at=?,version=version+1,updated_at=? WHERE id=? AND version=?")
|
|
70
|
+
.run(status, next?.toISOString() ?? null, now.toISOString(), schedule.id, schedule.version);
|
|
71
|
+
return skip ? [] : [{ run, schedule }];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async function execute(deps, claimed) {
|
|
75
|
+
let run = claimed.run;
|
|
76
|
+
try {
|
|
77
|
+
const template = deps.template(claimed.schedule.templateId, claimed.schedule.templateVersion);
|
|
78
|
+
if (template === undefined)
|
|
79
|
+
throw new ScheduleDispatchError("missing-template");
|
|
80
|
+
const document = freshTemplateDraft(deps, template.document, {
|
|
81
|
+
id: claimed.schedule.templateId,
|
|
82
|
+
version: claimed.schedule.templateVersion,
|
|
83
|
+
});
|
|
84
|
+
const fresh = {
|
|
85
|
+
...document,
|
|
86
|
+
variants: claimed.schedule.items.map((item) => ({
|
|
87
|
+
id: deps.uuid(),
|
|
88
|
+
title: item.title,
|
|
89
|
+
values: item.values,
|
|
90
|
+
})),
|
|
91
|
+
};
|
|
92
|
+
const draft = createDraft(deps, { id: deps.uuid(), document: fresh });
|
|
93
|
+
if (!draft.ok)
|
|
94
|
+
throw new ScheduleDispatchError("conflict");
|
|
95
|
+
const review = await reviewDraft(deps, { id: draft.value.draft.id, baseVersion: 1 });
|
|
96
|
+
if (!review.ok)
|
|
97
|
+
throw new ScheduleDispatchError(review.reason);
|
|
98
|
+
const high = review.value.estimates.reduce((sum, estimate) => sum + estimate.high, 0);
|
|
99
|
+
if (claimed.schedule.spendLimitCents !== null &&
|
|
100
|
+
(review.value.estimates.some((estimate) => estimate.unknown > 0) ||
|
|
101
|
+
high * 100 > claimed.schedule.spendLimitCents))
|
|
102
|
+
throw new ScheduleDispatchError("spend-limit");
|
|
103
|
+
run = {
|
|
104
|
+
...run,
|
|
105
|
+
requestId: review.value.id,
|
|
106
|
+
estimate: review.value.estimates,
|
|
107
|
+
};
|
|
108
|
+
recordRunStartIdentity(deps.db, run);
|
|
109
|
+
const started = await startPlayDraft(deps, {
|
|
110
|
+
draftId: draft.value.draft.id,
|
|
111
|
+
baseVersion: 1,
|
|
112
|
+
reviewId: review.value.id,
|
|
113
|
+
});
|
|
114
|
+
if (!started.ok)
|
|
115
|
+
throw new ScheduleDispatchError(started.reason);
|
|
116
|
+
run = {
|
|
117
|
+
...run,
|
|
118
|
+
requestId: started.value.requestId,
|
|
119
|
+
projectIds: started.value.projectIds,
|
|
120
|
+
};
|
|
121
|
+
recordRunDispatch(deps.db, run, deps.clock.now().toISOString());
|
|
122
|
+
run = {
|
|
123
|
+
...run,
|
|
124
|
+
status: "succeeded",
|
|
125
|
+
endedAt: deps.clock.now().toISOString(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
run = {
|
|
130
|
+
...run,
|
|
131
|
+
status: "failed",
|
|
132
|
+
endedAt: deps.clock.now().toISOString(),
|
|
133
|
+
error: error instanceof ScheduleDispatchError ? error.reason : "The scheduled run failed.",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
updateRun(deps.db, run);
|
|
137
|
+
}
|
|
138
|
+
class ScheduleDispatchError extends Error {
|
|
139
|
+
reason;
|
|
140
|
+
constructor(reason) {
|
|
141
|
+
super(reason);
|
|
142
|
+
this.reason = reason;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cadenceSchema, validTimeZone } from "./calendar.js";
|
|
3
|
+
const id = z.uuid();
|
|
4
|
+
const keywordName = z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1)
|
|
7
|
+
.max(200)
|
|
8
|
+
.refine((value) => value === value.trim(), "Keyword names cannot start or end with spaces.");
|
|
9
|
+
const item = z
|
|
10
|
+
.object({
|
|
11
|
+
title: z.string().trim().min(1).max(200),
|
|
12
|
+
values: z.record(keywordName, z.string().max(10000)).readonly(),
|
|
13
|
+
})
|
|
14
|
+
.strict()
|
|
15
|
+
.readonly();
|
|
16
|
+
const policy = z.enum(["skip", "run-once"]);
|
|
17
|
+
export const scheduleCreateSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
id,
|
|
20
|
+
name: z.string().trim().min(1).max(200),
|
|
21
|
+
templateId: id,
|
|
22
|
+
templateVersion: z.number().int().positive(),
|
|
23
|
+
cadence: cadenceSchema,
|
|
24
|
+
timezone: z.string().min(1).max(100).refine(validTimeZone, "Choose a valid IANA timezone."),
|
|
25
|
+
missedPolicy: policy.default("skip"),
|
|
26
|
+
overlapPolicy: z.literal("skip").default("skip"),
|
|
27
|
+
spendLimitCents: z.number().int().nonnegative().nullable().default(null),
|
|
28
|
+
items: z.array(item).max(49).default([]).readonly(),
|
|
29
|
+
})
|
|
30
|
+
.strict()
|
|
31
|
+
.readonly();
|
|
32
|
+
export const scheduleUpdateSchema = scheduleCreateSchema
|
|
33
|
+
.unwrap()
|
|
34
|
+
.extend({ baseVersion: z.number().int().positive(), mutationId: id })
|
|
35
|
+
.strict()
|
|
36
|
+
.readonly();
|
|
37
|
+
export const scheduleDeleteSchema = z
|
|
38
|
+
.object({ id, baseVersion: z.number().int().positive() })
|
|
39
|
+
.strict()
|
|
40
|
+
.readonly();
|
|
41
|
+
export const scheduleControlSchema = z
|
|
42
|
+
.object({ id, baseVersion: z.number().int().positive() })
|
|
43
|
+
.strict()
|
|
44
|
+
.readonly();
|
|
45
|
+
export const scheduleSummarySchema = z
|
|
46
|
+
.object({
|
|
47
|
+
id,
|
|
48
|
+
name: z.string(),
|
|
49
|
+
templateId: id,
|
|
50
|
+
templateVersion: z.number().int().positive(),
|
|
51
|
+
cadence: cadenceSchema,
|
|
52
|
+
timezone: z.string(),
|
|
53
|
+
missedPolicy: policy,
|
|
54
|
+
overlapPolicy: z.literal("skip"),
|
|
55
|
+
spendLimitCents: z.number().int().nonnegative().nullable(),
|
|
56
|
+
items: z.array(item).readonly(),
|
|
57
|
+
status: z.enum(["active", "paused", "completed", "canceled"]),
|
|
58
|
+
version: z.number().int().positive(),
|
|
59
|
+
nextRunAt: z.string().datetime({ offset: true }).nullable(),
|
|
60
|
+
createdAt: z.string(),
|
|
61
|
+
updatedAt: z.string(),
|
|
62
|
+
deletedAt: z.string().datetime({ offset: true }).nullable(),
|
|
63
|
+
})
|
|
64
|
+
.strict()
|
|
65
|
+
.readonly();
|
|
66
|
+
export const scheduleRunSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
id,
|
|
69
|
+
scheduleId: id,
|
|
70
|
+
scheduledFor: z.string().datetime({ offset: true }),
|
|
71
|
+
status: z.enum(["running", "succeeded", "failed", "skipped"]),
|
|
72
|
+
requestId: id.nullable(),
|
|
73
|
+
projectIds: z.array(z.string()).readonly(),
|
|
74
|
+
estimate: z.unknown().nullable(),
|
|
75
|
+
startedAt: z.string().datetime({ offset: true }),
|
|
76
|
+
endedAt: z.string().datetime({ offset: true }).nullable(),
|
|
77
|
+
error: z.string().nullable(),
|
|
78
|
+
})
|
|
79
|
+
.strict()
|
|
80
|
+
.readonly();
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { requestHash } from "../play-drafts/repo.js";
|
|
4
|
+
import { nextOccurrence } from "./calendar.js";
|
|
5
|
+
import { insertSchedule, runsForSchedule, scheduleById, scheduleRows, setScheduleStatus, tombstoneScheduleRow, updateScheduleRow, } from "./repo.js";
|
|
6
|
+
import { scheduleControlSchema, scheduleCreateSchema, scheduleDeleteSchema, scheduleUpdateSchema, } from "./schema.js";
|
|
7
|
+
export function listSchedules(deps) {
|
|
8
|
+
return scheduleRows(deps.db);
|
|
9
|
+
}
|
|
10
|
+
export function listScheduleRuns(deps, id) {
|
|
11
|
+
if (!z.uuid().safeParse(id).success)
|
|
12
|
+
return { ok: false, reason: "invalid-input" };
|
|
13
|
+
const runs = runsForSchedule(deps.db, id);
|
|
14
|
+
if (!scheduleById(deps.db, id) && runs.length === 0)
|
|
15
|
+
return { ok: false, reason: "not-found" };
|
|
16
|
+
return { ok: true, value: runs };
|
|
17
|
+
}
|
|
18
|
+
export function createSchedule(deps, input) {
|
|
19
|
+
const parsed = scheduleCreateSchema.safeParse(input);
|
|
20
|
+
if (!parsed.success)
|
|
21
|
+
return { ok: false, reason: "invalid-input" };
|
|
22
|
+
const hash = requestHash(parsed.data);
|
|
23
|
+
return transact(deps.db, () => {
|
|
24
|
+
const old = deps.db
|
|
25
|
+
.prepare("SELECT creation_hash,deleted_at FROM schedules WHERE id=?")
|
|
26
|
+
.get(parsed.data.id);
|
|
27
|
+
if (old !== undefined) {
|
|
28
|
+
const saved = z
|
|
29
|
+
.object({ creation_hash: z.string(), deleted_at: z.string().nullable() })
|
|
30
|
+
.parse(old);
|
|
31
|
+
if (saved.deleted_at !== null)
|
|
32
|
+
return { ok: false, reason: "conflict" };
|
|
33
|
+
return saved.creation_hash === hash
|
|
34
|
+
? { ok: true, value: scheduleById(deps.db, parsed.data.id) }
|
|
35
|
+
: { ok: false, reason: "conflict" };
|
|
36
|
+
}
|
|
37
|
+
const checked = checkTemplate(deps, parsed.data.templateId, parsed.data.templateVersion);
|
|
38
|
+
if (!checked.ok)
|
|
39
|
+
return checked;
|
|
40
|
+
const now = deps.clock.now();
|
|
41
|
+
const next = nextOccurrence(parsed.data.cadence, parsed.data.timezone, now);
|
|
42
|
+
if (next === null)
|
|
43
|
+
return { ok: false, reason: "not-due" };
|
|
44
|
+
const value = summary(parsed.data, next.toISOString(), now.toISOString());
|
|
45
|
+
insertSchedule(deps.db, value, hash);
|
|
46
|
+
return { ok: true, value };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function updateSchedule(deps, input) {
|
|
50
|
+
const parsed = scheduleUpdateSchema.safeParse(input);
|
|
51
|
+
if (!parsed.success)
|
|
52
|
+
return { ok: false, reason: "invalid-input" };
|
|
53
|
+
const hash = requestHash(parsed.data);
|
|
54
|
+
return transact(deps.db, () => {
|
|
55
|
+
const previous = scheduleById(deps.db, parsed.data.id);
|
|
56
|
+
if (!previous)
|
|
57
|
+
return { ok: false, reason: "not-found" };
|
|
58
|
+
const row = deps.db
|
|
59
|
+
.prepare("SELECT mutation_id,mutation_hash FROM schedules WHERE id=?")
|
|
60
|
+
.get(previous.id);
|
|
61
|
+
if (row !== undefined) {
|
|
62
|
+
const saved = z
|
|
63
|
+
.object({ mutation_id: z.string().nullable(), mutation_hash: z.string().nullable() })
|
|
64
|
+
.parse(row);
|
|
65
|
+
if (saved.mutation_id === parsed.data.mutationId)
|
|
66
|
+
return saved.mutation_hash === hash
|
|
67
|
+
? { ok: true, value: previous }
|
|
68
|
+
: { ok: false, reason: "conflict" };
|
|
69
|
+
}
|
|
70
|
+
if (previous.deletedAt !== null)
|
|
71
|
+
return { ok: false, reason: "conflict" };
|
|
72
|
+
if (previous.version !== parsed.data.baseVersion)
|
|
73
|
+
return { ok: false, reason: "conflict" };
|
|
74
|
+
if (previous.status === "canceled" || previous.status === "completed")
|
|
75
|
+
return { ok: false, reason: "conflict" };
|
|
76
|
+
const checked = checkTemplate(deps, parsed.data.templateId, parsed.data.templateVersion);
|
|
77
|
+
if (!checked.ok)
|
|
78
|
+
return checked;
|
|
79
|
+
const now = deps.clock.now();
|
|
80
|
+
const next = previous.status === "active"
|
|
81
|
+
? nextOccurrence(parsed.data.cadence, parsed.data.timezone, now)
|
|
82
|
+
: previous.nextRunAt === null
|
|
83
|
+
? null
|
|
84
|
+
: nextOccurrence(parsed.data.cadence, parsed.data.timezone, now);
|
|
85
|
+
const value = summary(parsed.data, next?.toISOString() ?? null, now.toISOString(), previous.status, previous.version + 1);
|
|
86
|
+
if (!updateScheduleRow(deps.db, value, parsed.data.mutationId, hash, parsed.data.baseVersion))
|
|
87
|
+
return { ok: false, reason: "conflict" };
|
|
88
|
+
return { ok: true, value };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export function pauseSchedule(deps, input) {
|
|
92
|
+
return control(deps, input, "paused");
|
|
93
|
+
}
|
|
94
|
+
export function resumeSchedule(deps, input) {
|
|
95
|
+
const parsed = scheduleControlSchema.safeParse(input);
|
|
96
|
+
if (!parsed.success)
|
|
97
|
+
return { ok: false, reason: "invalid-input" };
|
|
98
|
+
const previous = scheduleById(deps.db, parsed.data.id);
|
|
99
|
+
if (!previous)
|
|
100
|
+
return { ok: false, reason: "not-found" };
|
|
101
|
+
if (previous.deletedAt !== null)
|
|
102
|
+
return { ok: false, reason: "conflict" };
|
|
103
|
+
if (previous.version !== parsed.data.baseVersion || previous.status !== "paused")
|
|
104
|
+
return { ok: false, reason: "conflict" };
|
|
105
|
+
const next = nextOccurrence(previous.cadence, previous.timezone, deps.clock.now());
|
|
106
|
+
const status = next === null ? "completed" : "active";
|
|
107
|
+
if (!setScheduleStatus(deps.db, previous.id, previous.version, status, next?.toISOString() ?? null, deps.clock.now().toISOString()))
|
|
108
|
+
return { ok: false, reason: "conflict" };
|
|
109
|
+
return { ok: true, value: scheduleById(deps.db, previous.id) };
|
|
110
|
+
}
|
|
111
|
+
export function cancelSchedule(deps, input) {
|
|
112
|
+
return control(deps, input, "canceled");
|
|
113
|
+
}
|
|
114
|
+
export function deleteSchedule(deps, input) {
|
|
115
|
+
const parsed = scheduleDeleteSchema.safeParse(input);
|
|
116
|
+
if (!parsed.success)
|
|
117
|
+
return { ok: false, reason: "invalid-input" };
|
|
118
|
+
const previous = scheduleById(deps.db, parsed.data.id);
|
|
119
|
+
if (!previous)
|
|
120
|
+
return { ok: false, reason: "not-found" };
|
|
121
|
+
if (previous.deletedAt !== null)
|
|
122
|
+
return { ok: true, value: { deleted: true } };
|
|
123
|
+
if (previous.version !== parsed.data.baseVersion)
|
|
124
|
+
return { ok: false, reason: "conflict" };
|
|
125
|
+
if (previous.status !== "completed" && previous.status !== "canceled")
|
|
126
|
+
return { ok: false, reason: "cancel-required" };
|
|
127
|
+
const deletedAt = deps.clock.now().toISOString();
|
|
128
|
+
return transact(deps.db, () => tombstoneScheduleRow(deps.db, previous.id, previous.version, deletedAt)
|
|
129
|
+
? { ok: true, value: { deleted: true } }
|
|
130
|
+
: { ok: false, reason: "conflict" });
|
|
131
|
+
}
|
|
132
|
+
function control(deps, input, status) {
|
|
133
|
+
const parsed = scheduleControlSchema.safeParse(input);
|
|
134
|
+
if (!parsed.success)
|
|
135
|
+
return { ok: false, reason: "invalid-input" };
|
|
136
|
+
const previous = scheduleById(deps.db, parsed.data.id);
|
|
137
|
+
if (!previous)
|
|
138
|
+
return { ok: false, reason: "not-found" };
|
|
139
|
+
if (previous.deletedAt !== null)
|
|
140
|
+
return { ok: false, reason: "conflict" };
|
|
141
|
+
if (previous.version !== parsed.data.baseVersion ||
|
|
142
|
+
(previous.status !== "active" && !(status === "canceled" && previous.status === "paused")))
|
|
143
|
+
return { ok: false, reason: "conflict" };
|
|
144
|
+
const next = status === "canceled" ? null : previous.nextRunAt;
|
|
145
|
+
if (!setScheduleStatus(deps.db, previous.id, previous.version, status, next, deps.clock.now().toISOString()))
|
|
146
|
+
return { ok: false, reason: "conflict" };
|
|
147
|
+
return { ok: true, value: scheduleById(deps.db, previous.id) };
|
|
148
|
+
}
|
|
149
|
+
function checkTemplate(deps, id, version) {
|
|
150
|
+
const template = deps.template(id, version);
|
|
151
|
+
if (!template)
|
|
152
|
+
return { ok: false, reason: "missing-template" };
|
|
153
|
+
const sources = template.document.form.sources;
|
|
154
|
+
const mediaSources = ["audio", "images", "thumbnail"];
|
|
155
|
+
if (mediaSources.some((kind) => sources[kind] === "provide"))
|
|
156
|
+
return { ok: false, reason: "unsupported-media" };
|
|
157
|
+
return { ok: true, value: true };
|
|
158
|
+
}
|
|
159
|
+
function summary(input, nextRunAt, now, status = "active", version = 1) {
|
|
160
|
+
return {
|
|
161
|
+
id: input.id,
|
|
162
|
+
name: input.name,
|
|
163
|
+
templateId: input.templateId,
|
|
164
|
+
templateVersion: input.templateVersion,
|
|
165
|
+
cadence: input.cadence,
|
|
166
|
+
timezone: input.timezone,
|
|
167
|
+
missedPolicy: input.missedPolicy,
|
|
168
|
+
overlapPolicy: input.overlapPolicy,
|
|
169
|
+
spendLimitCents: input.spendLimitCents,
|
|
170
|
+
items: input.items,
|
|
171
|
+
status,
|
|
172
|
+
version,
|
|
173
|
+
nextRunAt,
|
|
174
|
+
createdAt: now,
|
|
175
|
+
updatedAt: now,
|
|
176
|
+
deletedAt: null,
|
|
177
|
+
};
|
|
178
|
+
}
|