@gentbajko/slopify 0.8.4 → 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/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,160 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
3
|
+
import { pieceKinds, pieceStates } from "../../kernel/runner/piece-repo.js";
|
|
4
|
+
import { runConfigSchema } from "../admission/schema.js";
|
|
5
|
+
import { outputSchema } from "../storage/schema.js";
|
|
6
|
+
const id = z
|
|
7
|
+
.string()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(64)
|
|
10
|
+
.regex(/^[0-9A-Za-z_-]+$/);
|
|
11
|
+
const workKey = z.string().min(1).max(256);
|
|
12
|
+
const tokens = z.record(workKey, id);
|
|
13
|
+
const cue = z
|
|
14
|
+
.object({
|
|
15
|
+
id,
|
|
16
|
+
text: z.string().trim().min(1).max(10000),
|
|
17
|
+
start: z.number().finite().nonnegative(),
|
|
18
|
+
end: z.number().finite().positive(),
|
|
19
|
+
})
|
|
20
|
+
.strict()
|
|
21
|
+
.refine((value) => value.end > value.start, {
|
|
22
|
+
path: ["end"],
|
|
23
|
+
message: "End must be after start.",
|
|
24
|
+
});
|
|
25
|
+
export const revisionContentSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
articleMarkdown: z.string().max(500000).optional(),
|
|
28
|
+
articleEdited: z.boolean().default(false),
|
|
29
|
+
provided: z
|
|
30
|
+
.object({
|
|
31
|
+
research: id.optional(),
|
|
32
|
+
article: id.optional(),
|
|
33
|
+
audio: id.optional(),
|
|
34
|
+
thumbnail: id.optional(),
|
|
35
|
+
})
|
|
36
|
+
.strict(),
|
|
37
|
+
imageOrder: z.array(id).max(60),
|
|
38
|
+
imageDefinitions: z.record(id, z
|
|
39
|
+
.object({
|
|
40
|
+
source: z.enum(["generate", "provide"]),
|
|
41
|
+
assetId: id.nullable(),
|
|
42
|
+
prompt: z.string().max(500000).nullable(),
|
|
43
|
+
templateKey: z.string().min(1).max(256).nullable().optional(),
|
|
44
|
+
})
|
|
45
|
+
.strict()),
|
|
46
|
+
narrationOverrides: z.record(workKey, z.discriminatedUnion("kind", [
|
|
47
|
+
z.object({ kind: z.literal("asset"), assetId: id }).strict(),
|
|
48
|
+
z.object({ kind: z.literal("text"), text: z.string().trim().min(1).max(500000) }).strict(),
|
|
49
|
+
])),
|
|
50
|
+
subtitleCues: z
|
|
51
|
+
.object({
|
|
52
|
+
audioFingerprint: z.string().min(1),
|
|
53
|
+
cues: z.array(cue),
|
|
54
|
+
})
|
|
55
|
+
.strict()
|
|
56
|
+
.optional(),
|
|
57
|
+
regenerationTokens: tokens,
|
|
58
|
+
promptTemplates: z.record(z.string().min(1), z.string().max(500000).nullable()),
|
|
59
|
+
})
|
|
60
|
+
.strict();
|
|
61
|
+
export const revisionEditSchema = z
|
|
62
|
+
.object({
|
|
63
|
+
config: runConfigSchema.strict(),
|
|
64
|
+
content: revisionContentSchema,
|
|
65
|
+
regenerate: z.array(workKey).optional(),
|
|
66
|
+
uploads: z
|
|
67
|
+
.array(z
|
|
68
|
+
.object({
|
|
69
|
+
stagedFileId: id,
|
|
70
|
+
destination: z.discriminatedUnion("kind", [
|
|
71
|
+
z
|
|
72
|
+
.object({ kind: z.literal("provided"), stage: z.enum(["audio", "thumbnail"]) })
|
|
73
|
+
.strict(),
|
|
74
|
+
z.object({ kind: z.literal("image"), imageKey: id }).strict(),
|
|
75
|
+
z.object({ kind: z.literal("narration"), key: workKey }).strict(),
|
|
76
|
+
]),
|
|
77
|
+
})
|
|
78
|
+
.strict())
|
|
79
|
+
.max(10000)
|
|
80
|
+
.optional(),
|
|
81
|
+
})
|
|
82
|
+
.strict();
|
|
83
|
+
export const saveRevisionSchema = z
|
|
84
|
+
.object({
|
|
85
|
+
baseRevisionId: id,
|
|
86
|
+
idempotencyKey: id,
|
|
87
|
+
edit: revisionEditSchema,
|
|
88
|
+
})
|
|
89
|
+
.strict();
|
|
90
|
+
export const restoreRevisionSchema = z
|
|
91
|
+
.object({
|
|
92
|
+
baseRevisionId: id,
|
|
93
|
+
idempotencyKey: id,
|
|
94
|
+
targetRevisionId: id,
|
|
95
|
+
})
|
|
96
|
+
.strict();
|
|
97
|
+
export const projectRevisionSchema = z.object({
|
|
98
|
+
id,
|
|
99
|
+
projectId: id,
|
|
100
|
+
parentId: id.nullable(),
|
|
101
|
+
restoredFromId: id.nullable(),
|
|
102
|
+
config: runConfigSchema,
|
|
103
|
+
content: revisionContentSchema,
|
|
104
|
+
fingerprints: z.record(workKey, z.string()),
|
|
105
|
+
createdAt: z.string(),
|
|
106
|
+
});
|
|
107
|
+
export const stagePieceSchema = z.object({
|
|
108
|
+
id,
|
|
109
|
+
stageId: id,
|
|
110
|
+
kind: z.enum(pieceKinds),
|
|
111
|
+
idx: z.number().int(),
|
|
112
|
+
state: z.enum(pieceStates),
|
|
113
|
+
payload: z.string().nullable(),
|
|
114
|
+
});
|
|
115
|
+
const manifestOutputSchema = z.object({
|
|
116
|
+
slot: z.string(),
|
|
117
|
+
workKey,
|
|
118
|
+
assetId: id,
|
|
119
|
+
output: outputSchema,
|
|
120
|
+
fingerprint: z.string(),
|
|
121
|
+
state: z.enum(["ready", "outdated", "review"]),
|
|
122
|
+
});
|
|
123
|
+
const manifestPieceSchema = z.object({
|
|
124
|
+
key: workKey,
|
|
125
|
+
stageKind: z.enum(stageKinds),
|
|
126
|
+
piece: stagePieceSchema,
|
|
127
|
+
assetId: id.nullable(),
|
|
128
|
+
fingerprint: z.string(),
|
|
129
|
+
});
|
|
130
|
+
const viewFields = {
|
|
131
|
+
recordId: id,
|
|
132
|
+
publicationId: id.nullable(),
|
|
133
|
+
selected: z.boolean(),
|
|
134
|
+
available: z.boolean(),
|
|
135
|
+
};
|
|
136
|
+
export const revisionViewSchema = z.object({
|
|
137
|
+
articleMarkdown: z.string().nullable(),
|
|
138
|
+
revision: projectRevisionSchema,
|
|
139
|
+
outputs: z.array(manifestOutputSchema.extend(viewFields)),
|
|
140
|
+
pieces: z.array(manifestPieceSchema.extend(viewFields)),
|
|
141
|
+
current: z.boolean(),
|
|
142
|
+
});
|
|
143
|
+
export const revisionSummarySchema = z.object({
|
|
144
|
+
id,
|
|
145
|
+
parentId: id.nullable(),
|
|
146
|
+
restoredFromId: id.nullable(),
|
|
147
|
+
title: z.string(),
|
|
148
|
+
createdAt: z.string(),
|
|
149
|
+
current: z.boolean(),
|
|
150
|
+
});
|
|
151
|
+
export const revisionMutationSuccessSchema = z.object({
|
|
152
|
+
ok: z.literal(true),
|
|
153
|
+
view: revisionViewSchema,
|
|
154
|
+
duplicate: z.boolean(),
|
|
155
|
+
});
|
|
156
|
+
export const baselineSuccessSchema = z.object({
|
|
157
|
+
ok: z.literal(true),
|
|
158
|
+
view: revisionViewSchema,
|
|
159
|
+
created: z.boolean(),
|
|
160
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { outputPath } from "../storage/layout.js";
|
|
4
|
+
import { currentRevisionId, outputsForRevision, piecesForRevision, revisionById } from "./repo.js";
|
|
5
|
+
export function getRevisionView(deps, projectId, revisionId) {
|
|
6
|
+
const revision = revisionById(deps.db, projectId, revisionId);
|
|
7
|
+
if (revision === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
const available = (assetId) => {
|
|
10
|
+
const row = deps.db
|
|
11
|
+
.prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
|
|
12
|
+
.get(projectId, assetId);
|
|
13
|
+
if (row === undefined)
|
|
14
|
+
throw new Error(`Revision asset ${assetId} is not registered.`);
|
|
15
|
+
const path = z.object({ path: z.string() }).parse(row).path;
|
|
16
|
+
return (statSync(outputPath(deps.paths, projectId, path), { throwIfNoEntry: false })?.isFile() ===
|
|
17
|
+
true);
|
|
18
|
+
};
|
|
19
|
+
const outputs = outputsForRevision(deps.db, projectId, revisionId).map((row) => ({
|
|
20
|
+
...row,
|
|
21
|
+
available: available(row.assetId),
|
|
22
|
+
}));
|
|
23
|
+
const pieces = piecesForRevision(deps.db, projectId, revisionId).map((row) => ({
|
|
24
|
+
...row,
|
|
25
|
+
available: row.assetId === null || available(row.assetId),
|
|
26
|
+
}));
|
|
27
|
+
return {
|
|
28
|
+
revision,
|
|
29
|
+
outputs,
|
|
30
|
+
pieces,
|
|
31
|
+
current: currentRevisionId(deps.db, projectId) === revisionId,
|
|
32
|
+
articleMarkdown: resolvedArticleMarkdown(deps, revision, outputs),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function resolvedArticleMarkdown(deps, revision, outputs) {
|
|
36
|
+
if (revision.content.articleEdited === true && revision.content.articleMarkdown !== undefined)
|
|
37
|
+
return revision.content.articleMarkdown;
|
|
38
|
+
const article = outputs.find((one) => one.selected && one.available && one.output.role === "article_md");
|
|
39
|
+
if (article !== undefined)
|
|
40
|
+
return readFileSync(outputPath(deps.paths, revision.projectId, article.output.path), "utf8");
|
|
41
|
+
return revision.config.sources.article === "provide"
|
|
42
|
+
? (revision.config.provided.article ?? null)
|
|
43
|
+
: null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/// <reference lib="esnext.temporal" />
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const cadenceSchema = z.discriminatedUnion("kind", [
|
|
4
|
+
z.object({ kind: z.literal("once"), at: z.string().datetime({ offset: true }) }).strict(),
|
|
5
|
+
z
|
|
6
|
+
.object({ kind: z.literal("daily"), time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/) })
|
|
7
|
+
.strict(),
|
|
8
|
+
z
|
|
9
|
+
.object({
|
|
10
|
+
kind: z.literal("weekly"),
|
|
11
|
+
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
|
12
|
+
days: z.array(z.number().int().min(0).max(6)).min(1).max(7).readonly(),
|
|
13
|
+
})
|
|
14
|
+
.strict(),
|
|
15
|
+
]);
|
|
16
|
+
export function validTimeZone(timeZone) {
|
|
17
|
+
try {
|
|
18
|
+
new Intl.DateTimeFormat("en", { timeZone }).format();
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function nextOccurrence(cadence, timeZone, now) {
|
|
26
|
+
if (!validTimeZone(timeZone))
|
|
27
|
+
return null;
|
|
28
|
+
if (cadence.kind === "once") {
|
|
29
|
+
const at = new Date(cadence.at);
|
|
30
|
+
return Number.isNaN(at.valueOf()) || at <= now ? null : at;
|
|
31
|
+
}
|
|
32
|
+
const current = Temporal.Instant.fromEpochMilliseconds(now.valueOf())
|
|
33
|
+
.toZonedDateTimeISO(timeZone)
|
|
34
|
+
.toPlainDate();
|
|
35
|
+
const wanted = Temporal.PlainTime.from(cadence.time);
|
|
36
|
+
for (let offset = 0; offset <= 8; offset++) {
|
|
37
|
+
const day = current.add({ days: offset });
|
|
38
|
+
if (cadence.kind === "weekly" && !cadence.days.includes(day.dayOfWeek % 7))
|
|
39
|
+
continue;
|
|
40
|
+
const candidate = day.toPlainDateTime(wanted).toZonedDateTime(timeZone, {
|
|
41
|
+
disambiguation: "compatible",
|
|
42
|
+
});
|
|
43
|
+
if (candidate.epochMilliseconds > now.valueOf())
|
|
44
|
+
return new Date(candidate.epochMilliseconds);
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
}
|