@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,95 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { changeCheckpoints, checkpointChangeSchema, readCheckpointStatus, replayCheckpointApproval, validateCheckpointApproval, } from "../../slices/checkpoints/change.js";
|
|
5
|
+
import { checkpointApprovalSchema, checkpointRowSchema } from "../../slices/checkpoints/schema.js";
|
|
6
|
+
import { withProjectControl } from "../../slices/control/lock.js";
|
|
7
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
8
|
+
const id = z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1)
|
|
11
|
+
.max(64)
|
|
12
|
+
.regex(/^[0-9A-Za-z_-]+$/);
|
|
13
|
+
const projectParam = z.object({ id });
|
|
14
|
+
const approvalParam = projectParam.extend({ checkpointId: id });
|
|
15
|
+
const approvalBody = checkpointApprovalSchema
|
|
16
|
+
.unwrap()
|
|
17
|
+
.omit({ projectId: true, checkpointId: true, approvedAt: true });
|
|
18
|
+
function refused(c, reason) {
|
|
19
|
+
const status = reason === "not-found" ? 404 : reason === "invalid-input" ? 400 : 409;
|
|
20
|
+
return problem(c, {
|
|
21
|
+
status,
|
|
22
|
+
title: titleOf(status),
|
|
23
|
+
detail: "Reload the current checkpoint status before approving or changing this gate.",
|
|
24
|
+
extensions: { reason },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function checkpointRoutes(deps) {
|
|
28
|
+
return buildCheckpointRoutes(deps);
|
|
29
|
+
}
|
|
30
|
+
function buildCheckpointRoutes(deps) {
|
|
31
|
+
const notify = (projectId, revisionId, wake) => {
|
|
32
|
+
try {
|
|
33
|
+
deps.hub.emit(projectId, { type: "project.updated", projectId, revisionId });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
deps.log.write("error", "checkpoint.notify", {
|
|
37
|
+
projectId,
|
|
38
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (wake) {
|
|
42
|
+
try {
|
|
43
|
+
deps.runner.tick(projectId);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
deps.log.write("error", "checkpoint.wake", {
|
|
47
|
+
projectId,
|
|
48
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
return new Hono()
|
|
54
|
+
.get("/:id/checkpoints", zValidator("param", projectParam, onInvalid), (c) => {
|
|
55
|
+
const result = readCheckpointStatus(deps, c.req.valid("param").id);
|
|
56
|
+
return result.ok ? c.json(result.value) : refused(c, result.reason);
|
|
57
|
+
})
|
|
58
|
+
.post("/:id/checkpoints/:checkpointId/approve", zValidator("param", approvalParam, onInvalid), zValidator("json", approvalBody, onInvalid), async (c) => {
|
|
59
|
+
const { id: projectId, checkpointId } = c.req.valid("param");
|
|
60
|
+
const identity = c.req.valid("json");
|
|
61
|
+
return withProjectControl(deps.db, projectId, () => {
|
|
62
|
+
const replay = replayCheckpointApproval(deps, { ...identity, projectId, checkpointId });
|
|
63
|
+
if (replay)
|
|
64
|
+
return replay.ok
|
|
65
|
+
? c.json({ checkpoint: replay.value, replayed: true })
|
|
66
|
+
: refused(c, replay.reason);
|
|
67
|
+
const valid = validateCheckpointApproval(deps, { ...identity, projectId, checkpointId });
|
|
68
|
+
if (!valid.ok)
|
|
69
|
+
return refused(c, valid.reason);
|
|
70
|
+
const authority = deps.runner.checkpoints;
|
|
71
|
+
if (!authority)
|
|
72
|
+
return refused(c, "conflict");
|
|
73
|
+
const result = authority.release(projectId, checkpointId, identity);
|
|
74
|
+
if (!result.ok && (result.reason !== "duplicate" || result.value === undefined))
|
|
75
|
+
return refused(c, result.reason);
|
|
76
|
+
const checkpoint = checkpointRowSchema.parse(result.value);
|
|
77
|
+
if (result.ok)
|
|
78
|
+
notify(projectId, identity.revisionId, false);
|
|
79
|
+
return c.json({ checkpoint, replayed: !result.ok });
|
|
80
|
+
});
|
|
81
|
+
})
|
|
82
|
+
.patch("/:id/checkpoints", zValidator("param", projectParam, onInvalid), zValidator("json", checkpointChangeSchema, onInvalid), async (c) => {
|
|
83
|
+
const projectId = c.req.valid("param").id;
|
|
84
|
+
const body = c.req.valid("json");
|
|
85
|
+
return withProjectControl(deps.db, projectId, () => {
|
|
86
|
+
const result = changeCheckpoints(deps, { ...body, projectId });
|
|
87
|
+
if (!result.ok)
|
|
88
|
+
return refused(c, result.reason);
|
|
89
|
+
if (result.value.changed)
|
|
90
|
+
notify(projectId, body.revisionId, result.value.released);
|
|
91
|
+
const status = readCheckpointStatus(deps, projectId);
|
|
92
|
+
return status.ok ? c.json(status.value) : refused(c, status.reason);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { providerStatuses } from "../../slices/settings/readiness.js";
|
|
3
|
+
/**
|
|
4
|
+
* A deliberately boring, secret-free support bundle. It is JSON rather than a
|
|
5
|
+
* filesystem archive so it can be copied from a remote/local browser and is
|
|
6
|
+
* still useful when a project output is unavailable.
|
|
7
|
+
*/
|
|
8
|
+
export function diagnosticsRoutes(deps) {
|
|
9
|
+
return new Hono().get("/", async (c) => {
|
|
10
|
+
const providers = await providerStatuses({ db: deps.db, probe: deps.probe });
|
|
11
|
+
const projectCount = deps.db.prepare("SELECT count(*) AS count FROM projects").get();
|
|
12
|
+
const schema = deps.db.prepare("SELECT max(version) AS version FROM schema_migrations").get();
|
|
13
|
+
c.header("Cache-Control", "no-store");
|
|
14
|
+
c.header("Content-Disposition", 'attachment; filename="slopify-diagnostics.json"');
|
|
15
|
+
return c.json({
|
|
16
|
+
appVersion: deps.version,
|
|
17
|
+
schemaVersion: typeof schema?.version === "number" ? schema.version : null,
|
|
18
|
+
platform: process.platform,
|
|
19
|
+
nodeMajor: Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10),
|
|
20
|
+
providers: providers.map((provider) => ({
|
|
21
|
+
id: provider.id,
|
|
22
|
+
family: provider.family,
|
|
23
|
+
displayName: provider.displayName,
|
|
24
|
+
readiness: provider.readiness,
|
|
25
|
+
...(provider.cliPath === undefined ? {} : { cliPath: provider.cliPath }),
|
|
26
|
+
})),
|
|
27
|
+
projects: { total: typeof projectCount?.count === "number" ? projectCount.count : 0 },
|
|
28
|
+
catalogue: deps.catalogue?.status() ?? { warning: "Catalogue unavailable." },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { zValidator } from "@hono/zod-validator";
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { draftAttachmentSchema } from "../../slices/play-drafts/schema.js";
|
|
8
|
+
import { readDraft } from "../../slices/play-drafts/service.js";
|
|
9
|
+
import { uploadDraftAttachment } from "../../slices/play-drafts/uploads.js";
|
|
10
|
+
import { stagingPath } from "../../slices/storage/layout.js";
|
|
11
|
+
import { stagedFileById } from "../../slices/storage/repo.js";
|
|
12
|
+
import { draftProblem } from "./draft-problem.js";
|
|
13
|
+
import { readMultipart } from "./multipart.js";
|
|
14
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
15
|
+
const params = z.object({ id: z.uuid(), attachmentId: z.uuid() });
|
|
16
|
+
const imageTypes = {
|
|
17
|
+
".png": "image/png",
|
|
18
|
+
".jpg": "image/jpeg",
|
|
19
|
+
".jpeg": "image/jpeg",
|
|
20
|
+
".webp": "image/webp",
|
|
21
|
+
".gif": "image/gif",
|
|
22
|
+
".avif": "image/avif",
|
|
23
|
+
};
|
|
24
|
+
// Inference preserves the route contracts consumed by the SPA client.
|
|
25
|
+
export function draftFileRoutes(service) {
|
|
26
|
+
return new Hono()
|
|
27
|
+
.put("/:id/attachments/:attachmentId/file", zValidator("param", params, onInvalid), async (c) => {
|
|
28
|
+
const { id, attachmentId } = c.req.valid("param");
|
|
29
|
+
const deps = service();
|
|
30
|
+
const result = await readMultipart(c.req.raw, (content) => uploadDraftAttachment(deps, { draftId: id, attachmentId, content }));
|
|
31
|
+
if (result === undefined)
|
|
32
|
+
return problem(c, {
|
|
33
|
+
status: 400,
|
|
34
|
+
title: titleOf(400),
|
|
35
|
+
detail: "The request carried no file part.",
|
|
36
|
+
});
|
|
37
|
+
return result.ok
|
|
38
|
+
? c.json(draftAttachmentSchema.parse(result.value))
|
|
39
|
+
: draftProblem(c, result);
|
|
40
|
+
})
|
|
41
|
+
.get("/:id/attachments/:attachmentId/file", zValidator("param", params, onInvalid), (c) => {
|
|
42
|
+
const deps = service();
|
|
43
|
+
const { id, attachmentId } = c.req.valid("param");
|
|
44
|
+
const view = readDraft(deps, id);
|
|
45
|
+
if (!view.ok)
|
|
46
|
+
return draftProblem(c, view);
|
|
47
|
+
const attachment = view.value.attachments.find((a) => a.id === attachmentId);
|
|
48
|
+
const staged = attachment?.stagedFileId === null || attachment?.stagedFileId === undefined
|
|
49
|
+
? undefined
|
|
50
|
+
: stagedFileById(deps.db, attachment.stagedFileId);
|
|
51
|
+
const contentType = attachment === undefined ? undefined : imageTypes[extname(attachment.name).toLowerCase()];
|
|
52
|
+
if (attachment?.state !== "ready" ||
|
|
53
|
+
attachment.kind === "audio" ||
|
|
54
|
+
staged?.state !== "staged" ||
|
|
55
|
+
staged.stageKind !== attachment.kind ||
|
|
56
|
+
contentType === undefined)
|
|
57
|
+
return problem(c, {
|
|
58
|
+
status: 404,
|
|
59
|
+
title: titleOf(404),
|
|
60
|
+
detail: "This draft attachment has no preview bytes.",
|
|
61
|
+
});
|
|
62
|
+
return c.body(Readable.toWeb(createReadStream(stagingPath(deps.paths, staged.path))), 200, {
|
|
63
|
+
"content-type": contentType,
|
|
64
|
+
"content-length": String(staged.bytes),
|
|
65
|
+
"x-content-type-options": "nosniff",
|
|
66
|
+
"cache-control": "no-store",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { problem, titleOf } from "./problem.js";
|
|
2
|
+
export function draftProblem(c, result) {
|
|
3
|
+
const status = result.reason === "not-found"
|
|
4
|
+
? 404
|
|
5
|
+
: ["invalid-edit", "readiness"].includes(result.reason)
|
|
6
|
+
? 400
|
|
7
|
+
: 409;
|
|
8
|
+
const { ok: _ok, ...extensions } = result;
|
|
9
|
+
return problem(c, {
|
|
10
|
+
status,
|
|
11
|
+
title: titleOf(status),
|
|
12
|
+
detail: "Review the saved draft and the listed fields.",
|
|
13
|
+
extensions,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { draftRow } from "../../slices/play-drafts/repo.js";
|
|
6
|
+
import { reviewDraft } from "../../slices/play-drafts/review.js";
|
|
7
|
+
import { createDraftInputSchema, discardDraftInputSchema, draftViewSchema, playReviewSchema, playStartResultSchema, saveDraftInputSchema, } from "../../slices/play-drafts/schema.js";
|
|
8
|
+
import { createDraft, discardDraft, forkDraft, listDrafts, readDraft, saveDraft, } from "../../slices/play-drafts/service.js";
|
|
9
|
+
import { startPlayDraft } from "../../slices/play-drafts/start.js";
|
|
10
|
+
import { draftFileRoutes } from "./draft-files.js";
|
|
11
|
+
import { draftProblem } from "./draft-problem.js";
|
|
12
|
+
import { onInvalid } from "./problem.js";
|
|
13
|
+
const idParam = z.object({ id: z.uuid() });
|
|
14
|
+
const versionBody = discardDraftInputSchema.unwrap().omit({ id: true });
|
|
15
|
+
const saveBody = saveDraftInputSchema.unwrap().omit({ id: true });
|
|
16
|
+
const startBody = versionBody.extend({ reviewId: z.uuid() }).strict();
|
|
17
|
+
const summariesSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
drafts: z.array(z
|
|
20
|
+
.object({
|
|
21
|
+
id: z.uuid(),
|
|
22
|
+
title: z.string(),
|
|
23
|
+
version: z.number().int().positive(),
|
|
24
|
+
updatedAt: z.string(),
|
|
25
|
+
readable: z.boolean(),
|
|
26
|
+
})
|
|
27
|
+
.strict()),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
// Inference preserves the route contracts consumed by the SPA client.
|
|
31
|
+
export function draftRoutes(deps) {
|
|
32
|
+
const service = () => {
|
|
33
|
+
if (deps === undefined)
|
|
34
|
+
throw new HTTPException(404);
|
|
35
|
+
return deps;
|
|
36
|
+
};
|
|
37
|
+
return new Hono()
|
|
38
|
+
.get("/", (c) => c.json(summariesSchema.parse({ drafts: listDrafts(service()) })))
|
|
39
|
+
.post("/", zValidator("json", createDraftInputSchema, onInvalid), (c) => {
|
|
40
|
+
const input = c.req.valid("json");
|
|
41
|
+
const existing = draftRow(service().db, input.id) !== undefined;
|
|
42
|
+
const result = createDraft(service(), input);
|
|
43
|
+
return result.ok
|
|
44
|
+
? c.json(draftViewSchema.parse(result.value), existing ? 200 : 201)
|
|
45
|
+
: draftProblem(c, result);
|
|
46
|
+
})
|
|
47
|
+
.get("/:id", zValidator("param", idParam, onInvalid), (c) => {
|
|
48
|
+
const result = readDraft(service(), c.req.valid("param").id);
|
|
49
|
+
return result.ok ? c.json(draftViewSchema.parse(result.value)) : draftProblem(c, result);
|
|
50
|
+
})
|
|
51
|
+
.put("/:id", zValidator("param", idParam, onInvalid), zValidator("json", saveBody, onInvalid), (c) => {
|
|
52
|
+
const result = saveDraft(service(), {
|
|
53
|
+
...c.req.valid("json"),
|
|
54
|
+
id: c.req.valid("param").id,
|
|
55
|
+
});
|
|
56
|
+
return result.ok ? c.json(draftViewSchema.parse(result.value)) : draftProblem(c, result);
|
|
57
|
+
})
|
|
58
|
+
.post("/:id/fork", zValidator("param", idParam, onInvalid), zValidator("json", createDraftInputSchema, onInvalid), (c) => {
|
|
59
|
+
const input = { ...c.req.valid("json"), sourceId: c.req.valid("param").id };
|
|
60
|
+
const existing = draftRow(service().db, input.id) !== undefined;
|
|
61
|
+
const result = forkDraft(service(), input);
|
|
62
|
+
return result.ok
|
|
63
|
+
? c.json(draftViewSchema.parse(result.value), existing ? 200 : 201)
|
|
64
|
+
: draftProblem(c, result);
|
|
65
|
+
})
|
|
66
|
+
.delete("/:id", zValidator("param", idParam, onInvalid), zValidator("json", versionBody, onInvalid), (c) => {
|
|
67
|
+
const result = discardDraft(service(), {
|
|
68
|
+
...c.req.valid("json"),
|
|
69
|
+
id: c.req.valid("param").id,
|
|
70
|
+
});
|
|
71
|
+
return result.ok
|
|
72
|
+
? c.json(z
|
|
73
|
+
.object({ discarded: z.literal(true) })
|
|
74
|
+
.strict()
|
|
75
|
+
.parse(result.value))
|
|
76
|
+
: draftProblem(c, result);
|
|
77
|
+
})
|
|
78
|
+
.post("/:id/review", zValidator("param", idParam, onInvalid), zValidator("json", versionBody, onInvalid), async (c) => {
|
|
79
|
+
const result = await reviewDraft(service(), {
|
|
80
|
+
...c.req.valid("json"),
|
|
81
|
+
id: c.req.valid("param").id,
|
|
82
|
+
});
|
|
83
|
+
return result.ok ? c.json(playReviewSchema.parse(result.value)) : draftProblem(c, result);
|
|
84
|
+
})
|
|
85
|
+
.post("/:id/start", zValidator("param", idParam, onInvalid), zValidator("json", startBody, onInvalid), async (c) => {
|
|
86
|
+
const result = await startPlayDraft(service(), {
|
|
87
|
+
...c.req.valid("json"),
|
|
88
|
+
draftId: c.req.valid("param").id,
|
|
89
|
+
});
|
|
90
|
+
return result.ok
|
|
91
|
+
? c.json(playStartResultSchema.parse(result.value), result.value.replayed ? 200 : 201)
|
|
92
|
+
: draftProblem(c, result);
|
|
93
|
+
})
|
|
94
|
+
.route("/", draftFileRoutes(service));
|
|
95
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { Busboy } from "@fastify/busboy";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
// The consumer cannot commit an upload until the entire multipart envelope is valid.
|
|
5
|
+
export async function readMultipart(request, consume) {
|
|
6
|
+
const contentType = request.headers.get("content-type");
|
|
7
|
+
if (contentType === null || !contentType.startsWith("multipart/form-data"))
|
|
8
|
+
throw new HTTPException(415, {
|
|
9
|
+
message: "An upload is sent as multipart/form-data with the file in one part.",
|
|
10
|
+
});
|
|
11
|
+
if (request.body === null)
|
|
12
|
+
throw new HTTPException(400, { message: "The request carried no body." });
|
|
13
|
+
let parser;
|
|
14
|
+
try {
|
|
15
|
+
parser = new Busboy({
|
|
16
|
+
headers: { "content-type": contentType },
|
|
17
|
+
limits: { files: 1, fields: 8 },
|
|
18
|
+
preservePath: true,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw new HTTPException(400, {
|
|
23
|
+
message: "The multipart boundary is missing or invalid.",
|
|
24
|
+
cause: error,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const source = Readable.fromWeb(request.body);
|
|
28
|
+
let file;
|
|
29
|
+
let operation;
|
|
30
|
+
let complete = () => undefined;
|
|
31
|
+
let rejectEnvelope = () => undefined;
|
|
32
|
+
const completed = new Promise((resolve, reject) => {
|
|
33
|
+
complete = resolve;
|
|
34
|
+
rejectEnvelope = reject;
|
|
35
|
+
});
|
|
36
|
+
// Observe immediately: the consumer may reject before it reaches the envelope check.
|
|
37
|
+
const envelope = completed.then(() => ({ ok: true }), (error) => ({ ok: false, error }));
|
|
38
|
+
const fail = (error) => {
|
|
39
|
+
rejectEnvelope(error);
|
|
40
|
+
file?.destroy(error);
|
|
41
|
+
source.destroy();
|
|
42
|
+
parser.destroy();
|
|
43
|
+
};
|
|
44
|
+
source.on("error", fail);
|
|
45
|
+
parser.on("error", (error) => fail(new HTTPException(400, {
|
|
46
|
+
message: "The multipart request is malformed or incomplete.",
|
|
47
|
+
cause: error,
|
|
48
|
+
})));
|
|
49
|
+
parser.on("finish", () => complete());
|
|
50
|
+
parser.on("file", (_field, stream, filename) => {
|
|
51
|
+
file = stream;
|
|
52
|
+
stream.on("error", fail);
|
|
53
|
+
async function* content() {
|
|
54
|
+
for await (const chunk of stream)
|
|
55
|
+
yield chunk;
|
|
56
|
+
const result = await envelope;
|
|
57
|
+
if (!result.ok)
|
|
58
|
+
throw result.error;
|
|
59
|
+
}
|
|
60
|
+
operation = Promise.resolve().then(() => consume(content(), typeof filename === "string" ? filename : ""));
|
|
61
|
+
void operation.then(() => stream.resume(), (error) => {
|
|
62
|
+
fail(error instanceof Error ? error : new Error("Upload failed", { cause: error }));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
const abort = () => fail(new Error("Upload request was aborted."));
|
|
66
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
67
|
+
if (request.signal.aborted)
|
|
68
|
+
abort();
|
|
69
|
+
else
|
|
70
|
+
source.pipe(parser);
|
|
71
|
+
try {
|
|
72
|
+
const parsed = await envelope;
|
|
73
|
+
if (operation !== undefined) {
|
|
74
|
+
const result = await operation;
|
|
75
|
+
if (!parsed.ok)
|
|
76
|
+
throw parsed.error;
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
if (!parsed.ok)
|
|
80
|
+
throw parsed.error;
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
request.signal.removeEventListener("abort", abort);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Tracks admitted HTTP writes so shutdown can stop admission, then drain them. */
|
|
2
|
+
export function createMutationLifecycle() {
|
|
3
|
+
let accepting = true;
|
|
4
|
+
let active = 0;
|
|
5
|
+
let drained;
|
|
6
|
+
let resolveDrain;
|
|
7
|
+
return {
|
|
8
|
+
begin: () => {
|
|
9
|
+
if (!accepting)
|
|
10
|
+
return undefined;
|
|
11
|
+
active++;
|
|
12
|
+
let released = false;
|
|
13
|
+
return () => {
|
|
14
|
+
if (released)
|
|
15
|
+
return;
|
|
16
|
+
released = true;
|
|
17
|
+
active--;
|
|
18
|
+
if (!accepting && active === 0)
|
|
19
|
+
resolveDrain?.();
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
stop: () => {
|
|
23
|
+
accepting = false;
|
|
24
|
+
if (active === 0)
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
drained ??= new Promise((resolve) => {
|
|
27
|
+
resolveDrain = resolve;
|
|
28
|
+
});
|
|
29
|
+
return drained;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Give admitted requests a grace period, then close their sockets and wait for their
|
|
34
|
+
* middleware `finally` blocks to release the database lease. */
|
|
35
|
+
export async function drainMutationsWithDeadline(drained, terminateConnections, timeoutMs) {
|
|
36
|
+
let timer;
|
|
37
|
+
const outcome = await Promise.race([
|
|
38
|
+
drained.then(() => "drained"),
|
|
39
|
+
new Promise((resolve) => {
|
|
40
|
+
timer = setTimeout(() => resolve("expired"), timeoutMs);
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
if (timer !== undefined)
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
if (outcome === "drained")
|
|
46
|
+
return;
|
|
47
|
+
terminateConnections();
|
|
48
|
+
await drained;
|
|
49
|
+
}
|
|
@@ -48,7 +48,12 @@ export function planningRoutes(deps) {
|
|
|
48
48
|
...f,
|
|
49
49
|
message: `${drafts.length > 1 ? `Video ${index + 1}: ` : ""}${f.message}`,
|
|
50
50
|
}));
|
|
51
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
draft: accepted,
|
|
53
|
+
rendered: renderPicked(picked, accepted.values),
|
|
54
|
+
templates: Object.fromEntries(picked.bodies.map(({ key, body }) => [key, body])),
|
|
55
|
+
fields,
|
|
56
|
+
};
|
|
52
57
|
});
|
|
53
58
|
};
|
|
54
59
|
return new Hono()
|
|
@@ -70,6 +75,12 @@ export function planningRoutes(deps) {
|
|
|
70
75
|
})
|
|
71
76
|
.post("/batch", zValidator("json", batchBody, onInvalid), async (c) => {
|
|
72
77
|
const input = c.req.valid("json");
|
|
78
|
+
if (input.draft.checkpoints?.length)
|
|
79
|
+
return problem(c, {
|
|
80
|
+
status: 409,
|
|
81
|
+
title: titleOf(409),
|
|
82
|
+
detail: "Queue checkpoint-enabled projects through Play Review.",
|
|
83
|
+
});
|
|
73
84
|
if (batchExists(deps.db, input.requestId))
|
|
74
85
|
return c.json({ queue: queueEntries(deps.db, input.requestId) });
|
|
75
86
|
const subtitles = input.draft.subtitles;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { createTemplateFromProject, templateFromProjectSchema, } from "../../slices/project-templates/from-project.js";
|
|
6
|
+
import { templateCreateSchema, templateDeleteSchema, templateInstantiateSchema, templateUpdateSchema, } from "../../slices/project-templates/schema.js";
|
|
7
|
+
import { createTemplate, deleteTemplate, instantiateTemplate, listTemplates, readTemplate, updateTemplate, } from "../../slices/project-templates/service.js";
|
|
8
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
9
|
+
const param = z.object({ id: z.uuid() });
|
|
10
|
+
function refused(c, result) {
|
|
11
|
+
const status = result.reason === "not-found"
|
|
12
|
+
? 404
|
|
13
|
+
: ["conflict", "referenced-by-schedule"].includes(result.reason)
|
|
14
|
+
? 409
|
|
15
|
+
: 400;
|
|
16
|
+
return problem(c, {
|
|
17
|
+
status,
|
|
18
|
+
title: titleOf(status),
|
|
19
|
+
detail: result.reason === "referenced-by-schedule"
|
|
20
|
+
? "Cancel and delete the schedules using this template before deleting it. Their run history will be retained."
|
|
21
|
+
: result.reason === "missing-prompt"
|
|
22
|
+
? "A selected prompt or entry is unavailable. Choose another before saving the template."
|
|
23
|
+
: "Reload the template and check its setup before trying again.",
|
|
24
|
+
extensions: { reason: result.reason },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function projectTemplateRoutes(deps) {
|
|
28
|
+
return routes(deps);
|
|
29
|
+
}
|
|
30
|
+
function routes(deps) {
|
|
31
|
+
const service = () => {
|
|
32
|
+
if (!deps)
|
|
33
|
+
throw new HTTPException(404);
|
|
34
|
+
return deps;
|
|
35
|
+
};
|
|
36
|
+
return new Hono()
|
|
37
|
+
.get("/", (c) => c.json({ templates: listTemplates(service()) }))
|
|
38
|
+
.post("/from-project/:projectId", zValidator("param", templateFromProjectSchema.unwrap().pick({ projectId: true }), onInvalid), zValidator("json", templateFromProjectSchema.unwrap().omit({ projectId: true }), onInvalid), (c) => {
|
|
39
|
+
const result = createTemplateFromProject(service(), {
|
|
40
|
+
...c.req.valid("json"),
|
|
41
|
+
projectId: c.req.valid("param").projectId,
|
|
42
|
+
});
|
|
43
|
+
return result.ok ? c.json(result.value, 201) : refused(c, result);
|
|
44
|
+
})
|
|
45
|
+
.post("/", zValidator("json", templateCreateSchema, onInvalid), (c) => {
|
|
46
|
+
const result = createTemplate(service(), c.req.valid("json"));
|
|
47
|
+
return result.ok ? c.json(result.value, 201) : refused(c, result);
|
|
48
|
+
})
|
|
49
|
+
.get("/:id", zValidator("param", param, onInvalid), zValidator("query", z.object({ version: z.coerce.number().int().positive().optional() }), onInvalid), (c) => {
|
|
50
|
+
const result = readTemplate(service(), c.req.valid("param").id, c.req.valid("query").version);
|
|
51
|
+
return result.ok ? c.json(result.value) : refused(c, result);
|
|
52
|
+
})
|
|
53
|
+
.put("/:id", zValidator("param", param, onInvalid), zValidator("json", templateUpdateSchema.unwrap().omit({ id: true }), onInvalid), (c) => {
|
|
54
|
+
const result = updateTemplate(service(), {
|
|
55
|
+
...c.req.valid("json"),
|
|
56
|
+
id: c.req.valid("param").id,
|
|
57
|
+
});
|
|
58
|
+
return result.ok ? c.json(result.value) : refused(c, result);
|
|
59
|
+
})
|
|
60
|
+
.delete("/:id", zValidator("param", param, onInvalid), zValidator("json", templateDeleteSchema.unwrap().omit({ id: true }), onInvalid), (c) => {
|
|
61
|
+
const result = deleteTemplate(service(), {
|
|
62
|
+
...c.req.valid("json"),
|
|
63
|
+
id: c.req.valid("param").id,
|
|
64
|
+
});
|
|
65
|
+
return result.ok ? c.json(result.value) : refused(c, result);
|
|
66
|
+
})
|
|
67
|
+
.post("/:id/instantiate", zValidator("param", param, onInvalid), zValidator("json", templateInstantiateSchema.unwrap().omit({ templateId: true }), onInvalid), (c) => {
|
|
68
|
+
const result = instantiateTemplate(service(), {
|
|
69
|
+
...c.req.valid("json"),
|
|
70
|
+
templateId: c.req.valid("param").id,
|
|
71
|
+
});
|
|
72
|
+
return result.ok ? c.json(result.value, 201) : refused(c, result);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -9,6 +9,8 @@ import { startRun } from "../../slices/admission/start.js";
|
|
|
9
9
|
import { withProjectControl } from "../../slices/control/lock.js";
|
|
10
10
|
import { resolveFont } from "../../slices/fonts/index.js";
|
|
11
11
|
import { pickTemplates, renderPicked } from "../../slices/library/slots.js";
|
|
12
|
+
import { adoptBaseline } from "../../slices/revisions/adopt.js";
|
|
13
|
+
import { currentRevisionId } from "../../slices/revisions/repo.js";
|
|
12
14
|
import { deleteProject } from "../../slices/storage/delete-project.js";
|
|
13
15
|
import { outputsOf, stagedFiles } from "../../slices/storage/repo.js";
|
|
14
16
|
import { record } from "../../slices/telemetry/record.js";
|
|
@@ -24,6 +26,7 @@ const idParam = z.object({
|
|
|
24
26
|
// generated from; see stagingRoutes.
|
|
25
27
|
export function projectRoutes(deps) {
|
|
26
28
|
const storage = {
|
|
29
|
+
catalogue: deps.catalogue,
|
|
27
30
|
db: deps.db,
|
|
28
31
|
paths: deps.paths,
|
|
29
32
|
ids: deps.ids,
|
|
@@ -40,13 +43,24 @@ export function projectRoutes(deps) {
|
|
|
40
43
|
log: deps.log,
|
|
41
44
|
appVersion: deps.version,
|
|
42
45
|
};
|
|
43
|
-
const storageForDelete = {
|
|
46
|
+
const storageForDelete = {
|
|
47
|
+
db: deps.db,
|
|
48
|
+
paths: deps.paths,
|
|
49
|
+
log: deps.log,
|
|
50
|
+
hasInflight: deps.runner.hasInflight,
|
|
51
|
+
};
|
|
44
52
|
const summarise = (project) => ({
|
|
45
53
|
...project,
|
|
46
54
|
status: derive(stagesOf(deps.db, project.id), project.paused),
|
|
47
55
|
});
|
|
48
56
|
return (new Hono()
|
|
49
57
|
.post("/", zValidator("json", runDraftSchema, onInvalid), async (c) => {
|
|
58
|
+
if (c.req.valid("json").checkpoints?.length)
|
|
59
|
+
return problem(c, {
|
|
60
|
+
status: 409,
|
|
61
|
+
title: titleOf(409),
|
|
62
|
+
detail: "Start checkpoint-enabled projects through Play Review.",
|
|
63
|
+
});
|
|
50
64
|
const requestedSubtitles = c.req.valid("json").subtitles;
|
|
51
65
|
if (requestedSubtitles !== undefined && requestedSubtitles.mode !== "off") {
|
|
52
66
|
try {
|
|
@@ -89,7 +103,7 @@ export function projectRoutes(deps) {
|
|
|
89
103
|
}
|
|
90
104
|
// Rendered from the values admit() has trimmed, so the stored text carries no
|
|
91
105
|
// padding the user did not intend.
|
|
92
|
-
const { project } = startRun(storage, admitted.draft, renderPicked(picked, admitted.draft.values));
|
|
106
|
+
const { project } = startRun(storage, admitted.draft, renderPicked(picked, admitted.draft.values), false, Object.fromEntries(picked.bodies.map(({ key, body }) => [key, body])));
|
|
93
107
|
// One event per project created. record() swallows its own
|
|
94
108
|
// failures, so a broken telemetry write cannot cost the user the run.
|
|
95
109
|
record(telemetry, "project.created", {});
|
|
@@ -124,7 +138,10 @@ export function projectRoutes(deps) {
|
|
|
124
138
|
detail: "No project has that id.",
|
|
125
139
|
});
|
|
126
140
|
}
|
|
141
|
+
if (deps.catalogue !== undefined)
|
|
142
|
+
adoptBaseline(deps, project.id);
|
|
127
143
|
return c.json({
|
|
144
|
+
revisionId: currentRevisionId(deps.db, project.id) ?? null,
|
|
128
145
|
project: summarise(project),
|
|
129
146
|
stages: stagesOf(deps.db, project.id),
|
|
130
147
|
outputs: outputsOf(deps.db, project.id),
|