@storyshelf/core 0.3.2 → 0.5.4
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 +38 -31
- package/dist/adapter/capture-queue.d.mts +29 -1
- package/dist/adapter/capture-runner.d.mts +18 -2
- package/dist/adapter/capture-runner.mjs +12 -1
- package/dist/adapter/capture-runner.mjs.map +1 -0
- package/dist/adapter/database.d.mts +67 -17
- package/dist/adapter/database.mjs +22 -55
- package/dist/adapter/database.mjs.map +1 -1
- package/dist/adapter/email-sender.d.mts +27 -0
- package/dist/adapter/email-sender.mjs +4 -0
- package/dist/adapter/metadata.d.mts +21 -15
- package/dist/adapter/notifier.d.mts +7 -0
- package/dist/adapter/notifier.mjs +6 -0
- package/dist/adapter/setup.d.mts +61 -0
- package/dist/adapter/setup.mjs +29 -0
- package/dist/adapter/setup.mjs.map +1 -0
- package/dist/adapter/webhook-events.d.mts +29 -0
- package/dist/adapter/webhook-events.mjs +83 -0
- package/dist/adapter/webhook-events.mjs.map +1 -0
- package/dist/adapters/notifier/events.d.mts +26 -0
- package/dist/adapters/notifier/events.mjs +66 -0
- package/dist/adapters/notifier/events.mjs.map +1 -0
- package/dist/adapters/notifier/format.d.mts +14 -0
- package/dist/adapters/notifier/format.mjs +96 -0
- package/dist/adapters/notifier/format.mjs.map +1 -0
- package/dist/adapters/notifier/provider.d.mts +32 -0
- package/dist/adapters/notifier/types.d.mts +56 -0
- package/dist/adapters/setup-runner.d.mts +23 -0
- package/dist/adapters/setup-runner.mjs +149 -0
- package/dist/adapters/setup-runner.mjs.map +1 -0
- package/dist/adapters/validate.d.mts +30 -0
- package/dist/adapters/validate.mjs +313 -0
- package/dist/adapters/validate.mjs.map +1 -0
- package/dist/auth.d.mts +94 -0
- package/dist/auth.mjs +4 -0
- package/dist/brand-packs.d.mts +16 -0
- package/dist/brand-packs.mjs +130 -0
- package/dist/brand-packs.mjs.map +1 -0
- package/dist/capture/adapter.d.mts +51 -1
- package/dist/capture/adapter.mjs.map +1 -1
- package/dist/capture/affected.d.mts +16 -0
- package/dist/capture/affected.mjs +64 -0
- package/dist/capture/affected.mjs.map +1 -0
- package/dist/capture/diff-record.mjs +30 -0
- package/dist/capture/diff-record.mjs.map +1 -0
- package/dist/capture/dispatch.d.mts +36 -0
- package/dist/capture/dispatch.mjs +88 -31
- package/dist/capture/dispatch.mjs.map +1 -1
- package/dist/capture/guards.mjs +31 -0
- package/dist/capture/guards.mjs.map +1 -0
- package/dist/capture/orchestrator.d.mts +41 -6
- package/dist/capture/orchestrator.mjs +151 -28
- package/dist/capture/orchestrator.mjs.map +1 -1
- package/dist/capture/pipeline.d.mts +30 -16
- package/dist/capture/pipeline.mjs +143 -39
- package/dist/capture/pipeline.mjs.map +1 -1
- package/dist/capture/recovery.d.mts +21 -0
- package/dist/capture/recovery.mjs +83 -0
- package/dist/capture/recovery.mjs.map +1 -0
- package/dist/capture/rediff.d.mts +35 -0
- package/dist/capture/rediff.mjs +77 -0
- package/dist/capture/rediff.mjs.map +1 -0
- package/dist/capture/sizing.d.mts +58 -0
- package/dist/capture/sizing.mjs +119 -0
- package/dist/capture/sizing.mjs.map +1 -0
- package/dist/capture/skip-checks.mjs +3 -3
- package/dist/capture/skip-checks.mjs.map +1 -1
- package/dist/capture/statics.d.mts +38 -0
- package/dist/capture/statics.mjs +38 -4
- package/dist/capture/statics.mjs.map +1 -1
- package/dist/capture/status-fanout.mjs +7 -2
- package/dist/capture/status-fanout.mjs.map +1 -1
- package/dist/capture/storybook.d.mts +31 -2
- package/dist/capture/storybook.mjs +77 -2
- package/dist/capture/storybook.mjs.map +1 -1
- package/dist/capture.d.mts +10 -4
- package/dist/capture.mjs +7 -2
- package/dist/config.d.mts +121 -4
- package/dist/config.mjs +55 -8
- package/dist/config.mjs.map +1 -1
- package/dist/index.d.mts +5 -56
- package/dist/index.mjs +4 -284
- package/dist/logger.d.mts +26 -17
- package/dist/logger.mjs +10 -5
- package/dist/logger.mjs.map +1 -1
- package/dist/models/baseline-status.d.mts +29 -0
- package/dist/models/baseline-status.mjs +26 -0
- package/dist/models/baseline-status.mjs.map +1 -0
- package/dist/models/baseline.d.mts +33 -0
- package/dist/models/baseline.mjs +35 -13
- package/dist/models/baseline.mjs.map +1 -1
- package/dist/models/build.d.mts +87 -0
- package/dist/models/build.mjs +83 -35
- package/dist/models/build.mjs.map +1 -1
- package/dist/models/capture-attempt.d.mts +40 -0
- package/dist/models/capture-attempt.mjs +85 -0
- package/dist/models/capture-attempt.mjs.map +1 -0
- package/dist/models/capture-log.d.mts +21 -0
- package/dist/models/capture-log.mjs +44 -0
- package/dist/models/capture-log.mjs.map +1 -0
- package/dist/models/comment.d.mts +29 -0
- package/dist/models/comment.mjs +11 -8
- package/dist/models/comment.mjs.map +1 -1
- package/dist/models/content-ref.d.mts +21 -0
- package/dist/models/content-ref.mjs +49 -0
- package/dist/models/content-ref.mjs.map +1 -0
- package/dist/models/credentials.d.mts +36 -0
- package/dist/models/credentials.mjs +103 -0
- package/dist/models/credentials.mjs.map +1 -0
- package/dist/models/label.d.mts +58 -0
- package/dist/models/label.mjs +32 -27
- package/dist/models/label.mjs.map +1 -1
- package/dist/models/member.d.mts +34 -0
- package/dist/models/member.mjs +20 -11
- package/dist/models/member.mjs.map +1 -1
- package/dist/models/notification-channel.d.mts +52 -0
- package/dist/models/notification-channel.mjs +93 -0
- package/dist/models/notification-channel.mjs.map +1 -0
- package/dist/models/notification-subscription.d.mts +44 -0
- package/dist/models/notification-subscription.mjs +89 -0
- package/dist/models/notification-subscription.mjs.map +1 -0
- package/dist/models/project-group-mapping.d.mts +30 -0
- package/dist/models/project-group-mapping.mjs +46 -0
- package/dist/models/project-group-mapping.mjs.map +1 -0
- package/dist/models/project.d.mts +54 -0
- package/dist/models/project.mjs +15 -11
- package/dist/models/project.mjs.map +1 -1
- package/dist/models/snapshot.d.mts +58 -0
- package/dist/models/snapshot.mjs +13 -7
- package/dist/models/snapshot.mjs.map +1 -1
- package/dist/models/status-config.d.mts +38 -0
- package/dist/models/status-config.mjs +12 -11
- package/dist/models/status-config.mjs.map +1 -1
- package/dist/models/token.d.mts +44 -0
- package/dist/models/token.mjs +18 -15
- package/dist/models/token.mjs.map +1 -1
- package/dist/models/user.d.mts +42 -0
- package/dist/models/user.mjs +65 -0
- package/dist/models/user.mjs.map +1 -0
- package/dist/models/webhook.d.mts +50 -0
- package/dist/models/webhook.mjs +12 -10
- package/dist/models/webhook.mjs.map +1 -1
- package/dist/models.d.mts +22 -0
- package/dist/models.mjs +23 -0
- package/dist/paths.mjs +29 -0
- package/dist/paths.mjs.map +1 -1
- package/dist/retention.d.mts +45 -0
- package/dist/retention.mjs +145 -0
- package/dist/retention.mjs.map +1 -0
- package/dist/schema/baseline.d.mts +1 -0
- package/dist/schema/build.d.mts +8 -0
- package/dist/schema/capture-attempt.d.mts +38 -0
- package/dist/schema/comment.d.mts +1 -1
- package/dist/schema/content-ref.d.mts +13 -0
- package/dist/schema/member.d.mts +2 -0
- package/dist/schema/notification.d.mts +35 -0
- package/dist/schema/project-group-mapping.d.mts +15 -0
- package/dist/schema/project.d.mts +4 -0
- package/dist/schema/snapshot.d.mts +7 -0
- package/dist/schema/token.d.mts +2 -0
- package/dist/schema/user.d.mts +11 -1
- package/dist/schema.d.mts +9 -46
- package/dist/schema.mjs +1 -55
- package/dist/test-helpers/create-project.d.mts +15 -0
- package/dist/test-helpers/create-project.mjs +28 -0
- package/dist/test-helpers/create-project.mjs.map +1 -0
- package/dist/test-helpers/database-contract.d.mts +13 -0
- package/dist/test-helpers/database-contract.mjs +58 -0
- package/dist/test-helpers/database-contract.mjs.map +1 -0
- package/dist/test-helpers/fake-adapters.d.mts +14 -0
- package/dist/test-helpers/fake-adapters.mjs +20 -0
- package/dist/test-helpers/fake-adapters.mjs.map +1 -0
- package/dist/test-helpers/fake-database.d.mts +11 -0
- package/dist/test-helpers/fake-database.mjs +104 -0
- package/dist/test-helpers/fake-database.mjs.map +1 -0
- package/dist/test-helpers/fake-storage.d.mts +14 -0
- package/dist/test-helpers/fake-storage.mjs +49 -0
- package/dist/test-helpers/fake-storage.mjs.map +1 -0
- package/dist/test-helpers/fake-tables.mjs +276 -0
- package/dist/test-helpers/fake-tables.mjs.map +1 -0
- package/dist/test-helpers/git-contract.d.mts +11 -0
- package/dist/test-helpers/git-contract.mjs +34 -0
- package/dist/test-helpers/git-contract.mjs.map +1 -0
- package/dist/test-helpers/notifier-contract.d.mts +11 -0
- package/dist/test-helpers/notifier-contract.mjs +34 -0
- package/dist/test-helpers/notifier-contract.mjs.map +1 -0
- package/dist/test-helpers/queue-contract.d.mts +11 -0
- package/dist/test-helpers/queue-contract.mjs +64 -0
- package/dist/test-helpers/queue-contract.mjs.map +1 -0
- package/dist/test-helpers/runner-contract.d.mts +11 -0
- package/dist/test-helpers/runner-contract.mjs +32 -0
- package/dist/test-helpers/runner-contract.mjs.map +1 -0
- package/dist/test-helpers/sql-chunks.mjs +138 -0
- package/dist/test-helpers/sql-chunks.mjs.map +1 -0
- package/dist/test-helpers/storage-contract.d.mts +11 -0
- package/dist/test-helpers/storage-contract.mjs +87 -0
- package/dist/test-helpers/storage-contract.mjs.map +1 -0
- package/dist/test-helpers.d.mts +13 -0
- package/dist/test-helpers.mjs +14 -0
- package/dist/tracing.mjs +63 -0
- package/dist/tracing.mjs.map +1 -0
- package/dist/types.d.mts +38 -5
- package/dist/types.mjs +18 -5
- package/dist/types.mjs.map +1 -1
- package/dist/urls.d.mts +12 -1
- package/dist/urls.mjs +22 -3
- package/dist/urls.mjs.map +1 -1
- package/dist/utils/encrypt.d.mts +48 -0
- package/dist/utils/encrypt.mjs +67 -13
- package/dist/utils/encrypt.mjs.map +1 -1
- package/dist/utils/hash.d.mts +17 -0
- package/dist/utils/hash.mjs +8 -2
- package/dist/utils/hash.mjs.map +1 -1
- package/dist/utils/http.d.mts +58 -0
- package/dist/utils/http.mjs +147 -0
- package/dist/utils/http.mjs.map +1 -0
- package/dist/utils/mime.d.mts +9 -0
- package/dist/utils/mime.mjs +33 -0
- package/dist/utils/mime.mjs.map +1 -0
- package/dist/utils/path-security.d.mts +12 -0
- package/dist/utils/path-security.mjs +24 -0
- package/dist/utils/path-security.mjs.map +1 -0
- package/dist/utils/redact.d.mts +17 -0
- package/dist/utils/redact.mjs +42 -0
- package/dist/utils/redact.mjs.map +1 -0
- package/dist/utils/timing.d.mts +19 -0
- package/dist/utils/timing.mjs +46 -0
- package/dist/utils/timing.mjs.map +1 -0
- package/dist/utils/ulid.d.mts +10 -0
- package/dist/utils/ulid.mjs +8 -8
- package/dist/utils/ulid.mjs.map +1 -1
- package/dist/utils.d.mts +12 -0
- package/dist/utils.mjs +13 -0
- package/package.json +69 -28
- package/dist/adapter/auth.d.mts +0 -50
- package/dist/adapter/auth.mjs +0 -10
- package/dist/adapter/auth.mjs.map +0 -1
- package/dist/adapter/init.d.mts +0 -58
- package/dist/adapter/init.mjs +0 -93
- package/dist/adapter/init.mjs.map +0 -1
- package/dist/adapters/webhook-events.mjs +0 -55
- package/dist/adapters/webhook-events.mjs.map +0 -1
- package/dist/ddl.d.mts +0 -8
- package/dist/ddl.mjs +0 -158
- package/dist/ddl.mjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/middleware/auth-gate.mjs +0 -36
- package/dist/middleware/auth-gate.mjs.map +0 -1
- package/dist/middleware/csrf.mjs +0 -40
- package/dist/middleware/csrf.mjs.map +0 -1
- package/dist/middleware/rate-limit.mjs +0 -39
- package/dist/middleware/rate-limit.mjs.map +0 -1
- package/dist/middleware/request-log.mjs +0 -28
- package/dist/middleware/request-log.mjs.map +0 -1
- package/dist/middleware/store-scope.mjs +0 -29
- package/dist/middleware/store-scope.mjs.map +0 -1
- package/dist/pages/build-detail.mjs +0 -339
- package/dist/pages/build-detail.mjs.map +0 -1
- package/dist/pages/build-diff-comments.mjs +0 -113
- package/dist/pages/build-diff-comments.mjs.map +0 -1
- package/dist/pages/build-diff-header.mjs +0 -163
- package/dist/pages/build-diff-header.mjs.map +0 -1
- package/dist/pages/build-diff-nav.mjs +0 -66
- package/dist/pages/build-diff-nav.mjs.map +0 -1
- package/dist/pages/build-diff-viewer.mjs +0 -197
- package/dist/pages/build-diff-viewer.mjs.map +0 -1
- package/dist/pages/build-diff.mjs +0 -90
- package/dist/pages/build-diff.mjs.map +0 -1
- package/dist/pages/compute-jobs.mjs +0 -198
- package/dist/pages/compute-jobs.mjs.map +0 -1
- package/dist/pages/label-detail.mjs +0 -228
- package/dist/pages/label-detail.mjs.map +0 -1
- package/dist/pages/login.mjs +0 -78
- package/dist/pages/login.mjs.map +0 -1
- package/dist/pages/project-builds.mjs +0 -231
- package/dist/pages/project-builds.mjs.map +0 -1
- package/dist/pages/project-create.mjs +0 -169
- package/dist/pages/project-create.mjs.map +0 -1
- package/dist/pages/project-settings.mjs +0 -102
- package/dist/pages/project-settings.mjs.map +0 -1
- package/dist/pages/projects.mjs +0 -184
- package/dist/pages/projects.mjs.map +0 -1
- package/dist/pages/root.mjs +0 -94
- package/dist/pages/root.mjs.map +0 -1
- package/dist/pages/settings-general.mjs +0 -192
- package/dist/pages/settings-general.mjs.map +0 -1
- package/dist/pages/settings-labels.mjs +0 -150
- package/dist/pages/settings-labels.mjs.map +0 -1
- package/dist/pages/settings-members.mjs +0 -125
- package/dist/pages/settings-members.mjs.map +0 -1
- package/dist/pages/settings-status.mjs +0 -152
- package/dist/pages/settings-status.mjs.map +0 -1
- package/dist/pages/settings-tests.mjs +0 -149
- package/dist/pages/settings-tests.mjs.map +0 -1
- package/dist/pages/settings-tokens.mjs +0 -96
- package/dist/pages/settings-tokens.mjs.map +0 -1
- package/dist/pages/settings-webhooks.mjs +0 -150
- package/dist/pages/settings-webhooks.mjs.map +0 -1
- package/dist/pages/storybook.mjs +0 -96
- package/dist/pages/storybook.mjs.map +0 -1
- package/dist/retention/purge.mjs +0 -70
- package/dist/retention/purge.mjs.map +0 -1
- package/dist/routers/admin.mjs +0 -42
- package/dist/routers/admin.mjs.map +0 -1
- package/dist/routers/assets.mjs +0 -25
- package/dist/routers/assets.mjs.map +0 -1
- package/dist/routers/auth.mjs +0 -78
- package/dist/routers/auth.mjs.map +0 -1
- package/dist/routers/builds.handlers.mjs +0 -184
- package/dist/routers/builds.handlers.mjs.map +0 -1
- package/dist/routers/builds.mjs +0 -185
- package/dist/routers/builds.mjs.map +0 -1
- package/dist/routers/comments.mjs +0 -90
- package/dist/routers/comments.mjs.map +0 -1
- package/dist/routers/health.mjs +0 -137
- package/dist/routers/health.mjs.map +0 -1
- package/dist/routers/helpers.mjs +0 -82
- package/dist/routers/helpers.mjs.map +0 -1
- package/dist/routers/htmx.mjs +0 -20
- package/dist/routers/htmx.mjs.map +0 -1
- package/dist/routers/labels.mjs +0 -110
- package/dist/routers/labels.mjs.map +0 -1
- package/dist/routers/media.mjs +0 -55
- package/dist/routers/media.mjs.map +0 -1
- package/dist/routers/members.mjs +0 -102
- package/dist/routers/members.mjs.map +0 -1
- package/dist/routers/projects.mjs +0 -122
- package/dist/routers/projects.mjs.map +0 -1
- package/dist/routers/schemas.mjs +0 -224
- package/dist/routers/schemas.mjs.map +0 -1
- package/dist/routers/settings-general.mjs +0 -95
- package/dist/routers/settings-general.mjs.map +0 -1
- package/dist/routers/settings-labels.mjs +0 -49
- package/dist/routers/settings-labels.mjs.map +0 -1
- package/dist/routers/settings-members.mjs +0 -30
- package/dist/routers/settings-members.mjs.map +0 -1
- package/dist/routers/settings-status.mjs +0 -73
- package/dist/routers/settings-status.mjs.map +0 -1
- package/dist/routers/settings-tokens.mjs +0 -40
- package/dist/routers/settings-tokens.mjs.map +0 -1
- package/dist/routers/settings-webhooks.mjs +0 -63
- package/dist/routers/settings-webhooks.mjs.map +0 -1
- package/dist/routers/settings.handlers.mjs +0 -93
- package/dist/routers/settings.handlers.mjs.map +0 -1
- package/dist/routers/settings.mjs +0 -23
- package/dist/routers/settings.mjs.map +0 -1
- package/dist/routers/snapshots.mjs +0 -144
- package/dist/routers/snapshots.mjs.map +0 -1
- package/dist/routers/status-configs.mjs +0 -101
- package/dist/routers/status-configs.mjs.map +0 -1
- package/dist/routers/storybook.mjs +0 -120
- package/dist/routers/storybook.mjs.map +0 -1
- package/dist/routers/tokens.mjs +0 -81
- package/dist/routers/tokens.mjs.map +0 -1
- package/dist/routers/ui.mjs +0 -165
- package/dist/routers/ui.mjs.map +0 -1
- package/dist/routers/webhooks.mjs +0 -90
- package/dist/routers/webhooks.mjs.map +0 -1
- package/dist/schema/baseline.mjs +0 -22
- package/dist/schema/baseline.mjs.map +0 -1
- package/dist/schema/build.mjs +0 -29
- package/dist/schema/build.mjs.map +0 -1
- package/dist/schema/comment.mjs +0 -26
- package/dist/schema/comment.mjs.map +0 -1
- package/dist/schema/label.mjs +0 -30
- package/dist/schema/label.mjs.map +0 -1
- package/dist/schema/member.mjs +0 -19
- package/dist/schema/member.mjs.map +0 -1
- package/dist/schema/project.mjs +0 -25
- package/dist/schema/project.mjs.map +0 -1
- package/dist/schema/snapshot.mjs +0 -34
- package/dist/schema/snapshot.mjs.map +0 -1
- package/dist/schema/status-config.mjs +0 -20
- package/dist/schema/status-config.mjs.map +0 -1
- package/dist/schema/token.mjs +0 -19
- package/dist/schema/token.mjs.map +0 -1
- package/dist/schema/user.mjs +0 -19
- package/dist/schema/user.mjs.map +0 -1
- package/dist/schema/webhook.mjs +0 -20
- package/dist/schema/webhook.mjs.map +0 -1
- package/dist/schema.mjs.map +0 -1
- package/dist/store.mjs +0 -20
- package/dist/store.mjs.map +0 -1
- package/dist/ui/document.mjs +0 -348
- package/dist/ui/document.mjs.map +0 -1
- package/dist/ui/feedback.mjs +0 -24
- package/dist/ui/feedback.mjs.map +0 -1
- package/dist/ui/forms.mjs +0 -117
- package/dist/ui/forms.mjs.map +0 -1
- package/dist/ui/styles.mjs +0 -174
- package/dist/ui/styles.mjs.map +0 -1
- package/dist/ui/theme.mjs +0 -44
- package/dist/ui/theme.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"orchestrator.mjs","names":[],"sources":["../../src/capture/orchestrator.ts"],"sourcesContent":["import type { Logger } from \"pino\";\nimport type { CaptureRunner } from \"../adapters/capture-runner.ts\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport { DEFAULT_VIEWPORTS, isDisabledStory, isFlakyStory } from \"./adapter.ts\";\nimport type { Viewport } from \"./adapter.ts\";\nimport { persistCapture } from \"./pipeline.ts\";\nimport { extractStorybookToScratch, persistStorybookStatics } from \"./statics.ts\";\nimport { StorybookAdapter } from \"./storybook.ts\";\n\n/** Inputs for running a capture job against a Storybook build. */\nexport interface CaptureJobOptions {\n db: DatabaseAdapter;\n storage: StorageAdapter;\n runner: CaptureRunner;\n scratchDir: string;\n viewports?: Viewport[];\n logger?: Logger;\n /** Server secret for decrypting webhook secrets at send time. */\n secret?: string | undefined;\n}\n/**\n * Run the full capture for a build: extract, render, persist, and finalize.\n *\n * @param input - Build id plus the originating request id.\n * @param options - Adapters, scratch dir, viewports, and logger.\n */\nexport async function executeCaptureJob(\n input: { buildId: string; reqId?: string },\n options: CaptureJobOptions,\n): Promise<void> {\n const builds = new BuildModel(options.db);\n const { build, project } = await loadTarget(options, input.buildId);\n const logger = options.logger?.child({ buildId: input.buildId, reqId: input.reqId });\n await builds.setStatus(build.id, \"capturing\");\n\n const startTime = performance.now();\n let extractedDir: string | undefined;\n try {\n const extractStart = performance.now();\n extractedDir = await extractStorybookToScratch(\n options.storage,\n options.scratchDir,\n project.id,\n build.id,\n );\n const extractDuration = performance.now() - extractStart;\n logger?.info({ durationMs: Math.round(extractDuration) }, \"storybook extracted\");\n\n // Persist the extracted statics to storage so the published Storybook\n // (`storybookDir`) can be served after the scratch dir is cleaned up.\n const staticsStart = performance.now();\n await persistStorybookStatics(options.storage, extractedDir, project.id, build.id);\n logger?.info(\n { durationMs: Math.round(performance.now() - staticsStart) },\n \"storybook statics persisted\",\n );\n\n const adapter = new StorybookAdapter();\n const discovered = await adapter.discover(extractedDir);\n const stories = discovered.filter((s) => !isDisabledStory(s));\n const viewports = options.viewports ?? DEFAULT_VIEWPORTS;\n\n const renderStart = performance.now();\n const result = await options.runner.render({\n buildId: build.id,\n storybookDir: extractedDir,\n stories,\n viewports,\n logger,\n executePlay: project.executePlay ?? false,\n playTimeoutMs: project.playTimeoutMs ?? 10_000,\n });\n const renderDuration = performance.now() - renderStart;\n logger?.info(\n { durationMs: Math.round(renderDuration), storyCount: stories.length },\n \"stories rendered\",\n );\n\n const flakyStoryIds = new Set(stories.filter((s) => isFlakyStory(s)).map((s) => s.id));\n const blockingFailed = new Set<string>();\n const flakyFailed = new Set<string>();\n for (const f of result.failures) {\n if (flakyStoryIds.has(f.storyId)) flakyFailed.add(f.storyId);\n else blockingFailed.add(f.storyId);\n }\n\n const persistStart = performance.now();\n await persistCapture(\n {\n db: options.db,\n storage: options.storage,\n project,\n build,\n viewports,\n captures: result.captures,\n logger,\n secret: options.secret,\n },\n blockingFailed,\n flakyFailed,\n );\n const persistDuration = performance.now() - persistStart;\n logger?.info({ durationMs: Math.round(persistDuration) }, \"capture persisted\");\n\n const totalDuration = performance.now() - startTime;\n logger?.info({ durationMs: Math.round(totalDuration) }, \"capture completed\");\n } catch (error) {\n const totalDuration = performance.now() - startTime;\n logger?.error({ durationMs: Math.round(totalDuration), err: error }, \"capture failed\");\n await builds.setStatus(build.id, \"failed\").catch((markError: unknown) => {\n logger?.error({ err: markError }, \"failed to mark build failed after capture error\");\n });\n throw error;\n }\n}\n\nasync function loadTarget(\n options: CaptureJobOptions,\n buildId: string,\n): Promise<{ build: Build; project: Project }> {\n const build = await new BuildModel(options.db).get(buildId);\n if (!build) {\n throw new Error(`Build not found: ${buildId}`);\n }\n const project = await new ProjectModel(options.db).get(build.projectId);\n if (!project) {\n throw new Error(`Project not found: ${build.projectId}`);\n }\n return { build, project };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"orchestrator.mjs","names":[],"sources":["../../src/capture/orchestrator.ts"],"sourcesContent":["import type { Logger } from \"pino\";\nimport type { CaptureRunner, RenderResult } from \"../adapters/capture-runner.ts\";\nimport type { BrowserName } from \"../adapters/capture-runner.ts\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { BuildModel, type BuildTables } from \"../models/build.ts\";\nimport {\n emitAttemptLog,\n type AttemptLogRecorder,\n type CaptureAttemptTables,\n} from \"../models/capture-attempt.ts\";\nimport type { CaptureLogTables } from \"../models/capture-log.ts\";\nimport { ProjectModel, type ProjectTables } from \"../models/project.ts\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport { parentContext, withSpan } from \"../tracing.ts\";\nimport type { SecretInput } from \"../utils/encrypt.ts\";\nimport { roundedTimings, runWithTimings, timed } from \"../utils/timing.ts\";\nimport { DEFAULT_VIEWPORTS, isDisabledStory, isFlakyStory } from \"./adapter.ts\";\nimport type { Viewport } from \"./adapter.ts\";\nimport { partitionAffectedStories } from \"./affected.ts\";\nimport { persistCapture, type PipelineTables } from \"./pipeline.ts\";\nimport { resolveViewports } from \"./sizing.ts\";\nimport { extractStorybookToScratch, persistStorybookStatics } from \"./statics.ts\";\nimport { StorybookAdapter } from \"./storybook.ts\";\n\n/** Table handles required by the orchestrator. */\nexport type OrchestratorTables = BuildTables &\n ProjectTables &\n PipelineTables &\n CaptureAttemptTables &\n CaptureLogTables;\n\n/** Inputs for running a capture job against a Storybook build. */\nexport interface CaptureJobOptions {\n db: DatabaseAdapter;\n tables: OrchestratorTables;\n storage: StorageAdapter;\n runner: CaptureRunner;\n scratchDir: string;\n viewports?: Viewport[];\n logger?: Logger;\n /** Server secret for decrypting webhook secrets at send time. */\n secret?: SecretInput;\n /** Render budget override (tests use a short fuse). */\n renderTimeoutMs?: number | undefined;\n}\n\n/** Default budget for one render call before the orchestrator cancels it. */\nexport const RENDER_TIMEOUT_MS = 15 * 60_000;\n\n/** Failure error text cap (persisted rows and logs stay bounded). */\nconst FAILURE_ERROR_MAX = 500;\n\n/** Render input assembled by the orchestrator for one build. */\ninterface RenderRequest {\n buildId: string;\n storybookDir: string;\n stories: Parameters<CaptureRunner[\"render\"]>[0][\"stories\"];\n viewports: Parameters<CaptureRunner[\"render\"]>[0][\"viewports\"];\n logger: Logger | undefined;\n executePlay: boolean;\n playTimeoutMs: number;\n runA11y: boolean;\n browser: BrowserName;\n}\n\n/** Cap failure error text so one chatty runner cannot flood rows and logs. */\nfunction capFailures(result: RenderResult): RenderResult {\n return {\n captures: result.captures,\n failures: result.failures.map((failure) => ({\n ...failure,\n error:\n failure.error.length > FAILURE_ERROR_MAX\n ? failure.error.slice(0, FAILURE_ERROR_MAX)\n : failure.error,\n })),\n };\n}\n\n/** Render with a timeout; cancels the runner when the budget expires. */\nasync function renderWithTimeout(\n runner: CaptureRunner,\n request: RenderRequest,\n timeoutMs: number,\n): Promise<RenderResult> {\n let timedOut = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n timedOut = true;\n reject(new Error(`Render timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n });\n timer?.unref?.();\n try {\n return capFailures(await Promise.race([runner.render(request), timeout]));\n } catch (error) {\n if (timedOut) {\n await runner.cancel(request.buildId).catch(() => {});\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n}\n\n/** Pointer to the attempt row owning this run's log history. */\nexport interface CaptureAttemptRef {\n id: string;\n attemptNo: number;\n projectId: string;\n}\n\n/** Inputs for running a capture job against a Storybook build. */\nexport interface CaptureJobInput {\n buildId: string;\n reqId?: string;\n /**\n * W3C `traceparent` from the enqueueing request or queue payload.\n * Continues the inbound trace instead of starting a root span.\n */\n traceparent?: string;\n /** Attempt row owning this run; when omitted no per-attempt logs are stored. */\n attempt?: CaptureAttemptRef;\n /** Recorder mirroring phase logs into the attempt's log history. */\n recordLog?: AttemptLogRecorder;\n}\n/**\n * Run the full capture for a build: extract, render, persist, and finalize.\n *\n * The run executes inside a `capture.job` span (continuing `input.traceparent`\n * when present) with one child span per pipeline phase.\n *\n * @param input - Build id plus the originating request id.\n * @param options - Adapters, scratch dir, viewports, and logger.\n * @returns Story and blocking-failure counts for the attempt row.\n */\nexport async function executeCaptureJob(\n input: CaptureJobInput,\n options: CaptureJobOptions,\n): Promise<{ storyCount: number; failedCount: number }> {\n const builds = new BuildModel(options.db, options.tables);\n const { build, project } = await loadTarget(options, input.buildId);\n return await withSpan(\n \"capture.job\",\n async () => {\n const { result, timings } = await runWithTimings(\n async () => await runCapturePhases(input, options, builds, build, project),\n );\n options.logger\n ?.child({ buildId: build.id, ...(input.reqId ? { reqId: input.reqId } : {}) })\n ?.info({ timings: roundedTimings(timings) }, \"capture timings\");\n return result;\n },\n {\n \"storyshelf.build_id\": build.id,\n ...(input.reqId ? { \"storyshelf.req_id\": input.reqId } : {}),\n ...(input.attempt ? { \"storyshelf.attempt_no\": input.attempt.attemptNo } : {}),\n },\n parentContext(input.traceparent),\n );\n}\n\n/** Execute the phase pipeline inside the `capture.job` span. */\nasync function runCapturePhases(\n input: CaptureJobInput,\n options: CaptureJobOptions,\n builds: BuildModel,\n build: Build,\n project: Project,\n): Promise<{ storyCount: number; failedCount: number }> {\n const logger = options.logger?.child({\n buildId: input.buildId,\n reqId: input.reqId,\n ...(input.attempt ? { attemptNo: input.attempt.attemptNo } : {}),\n });\n await builds.setStatus(build.id, \"capturing\");\n\n const startTime = performance.now();\n let extractedDir: string | undefined;\n try {\n const extractStart = performance.now();\n extractedDir = await timed(\n \"capture.extract\",\n async () =>\n await withSpan(\"capture.extract\", async () => {\n return await extractStorybookToScratch(\n options.storage,\n options.scratchDir,\n project.id,\n build.id,\n );\n }),\n );\n const extractDuration = performance.now() - extractStart;\n logger?.info({ durationMs: Math.round(extractDuration) }, \"storybook extracted\");\n await emitAttemptLog(input.recordLog, logger, \"info\", \"storybook extracted\", {\n durationMs: Math.round(extractDuration),\n });\n\n // Persist the extracted statics to storage so the published Storybook\n // (`storybookDir`) can be served after the scratch dir is cleaned up.\n // Copy to a const: closures below need the narrowed string type.\n const storybookDir: string = extractedDir;\n const staticsStart = performance.now();\n await timed(\n \"capture.persist-statics\",\n async () =>\n await withSpan(\"capture.persist-statics\", async () => {\n await persistStorybookStatics(options.storage, storybookDir, project.id, build.id);\n }),\n );\n logger?.info(\n { durationMs: Math.round(performance.now() - staticsStart) },\n \"storybook statics persisted\",\n );\n await emitAttemptLog(input.recordLog, logger, \"info\", \"storybook statics persisted\", {\n durationMs: Math.round(performance.now() - staticsStart),\n });\n\n const adapter = new StorybookAdapter();\n const discovered = await adapter.discover(extractedDir);\n const stories = discovered.filter((s) => !isDisabledStory(s));\n // Resolve viewports via single helper (project JSON → server override → default)\n const rawViewports = (project as unknown as { viewports?: string | null }).viewports;\n const viewports = resolveViewports(rawViewports, DEFAULT_VIEWPORTS, options.viewports);\n const browser = (project as unknown as { browser?: string }).browser as BrowserName | undefined;\n\n const partition = await partitionAffectedStories({\n db: options.db,\n tables: options.tables,\n projectId: project.id,\n branch: build.gitBranch,\n defaultBranch: project.gitDefaultBranch,\n stories,\n viewports,\n affectedPaths: affectedPathsFor(build),\n });\n logger?.info(\n {\n rendered: partition.render.length,\n inherited: partition.inherited.length,\n total: stories.length,\n },\n \"affected capture partitioned\",\n );\n await emitAttemptLog(input.recordLog, logger, \"info\", \"affected capture partitioned\", {\n rendered: partition.render.length,\n inherited: partition.inherited.length,\n total: stories.length,\n });\n\n const renderStart = performance.now();\n const renderTimeoutMs = options.renderTimeoutMs ?? RENDER_TIMEOUT_MS;\n const result = await timed(\n \"capture.render\",\n async () =>\n await withSpan(\n \"capture.render\",\n async () => {\n if (partition.render.length === 0) {\n return { captures: [], failures: [] };\n }\n return await renderWithTimeout(\n options.runner,\n {\n buildId: build.id,\n storybookDir,\n stories: partition.render,\n viewports,\n logger,\n executePlay: project.executePlay ?? false,\n playTimeoutMs: project.playTimeoutMs ?? 10_000,\n runA11y: project.runA11y ?? false,\n browser: browser ?? \"chromium\",\n },\n renderTimeoutMs,\n );\n },\n { \"storyshelf.render_count\": partition.render.length },\n ),\n );\n const renderDuration = performance.now() - renderStart;\n logger?.info(\n { durationMs: Math.round(renderDuration), storyCount: stories.length },\n \"stories rendered\",\n );\n await emitAttemptLog(input.recordLog, logger, \"info\", \"stories rendered\", {\n durationMs: Math.round(renderDuration),\n storyCount: stories.length,\n });\n\n const flakyStoryIds = new Set(stories.filter((s) => isFlakyStory(s)).map((s) => s.id));\n const blockingFailed = new Set<string>();\n const flakyFailed = new Set<string>();\n const a11yFailed = new Set<string>();\n for (const f of result.failures) {\n if (f.error.startsWith(\"a11y:\")) a11yFailed.add(f.storyId);\n else if (flakyStoryIds.has(f.storyId)) flakyFailed.add(f.storyId);\n else blockingFailed.add(f.storyId);\n }\n\n const persistStart = performance.now();\n await timed(\n \"capture.persist\",\n async () =>\n await withSpan(\"capture.persist\", async () => {\n await persistCapture(\n {\n db: options.db,\n tables: options.tables,\n storage: options.storage,\n project,\n build,\n viewports,\n captures: result.captures,\n inherited: partition.inherited,\n logger,\n recordLog: input.recordLog,\n secret: options.secret,\n },\n blockingFailed,\n flakyFailed,\n a11yFailed,\n );\n }),\n );\n const persistDuration = performance.now() - persistStart;\n logger?.info({ durationMs: Math.round(persistDuration) }, \"capture persisted\");\n await emitAttemptLog(input.recordLog, logger, \"info\", \"capture persisted\", {\n durationMs: Math.round(persistDuration),\n });\n\n const totalDuration = performance.now() - startTime;\n logger?.info({ durationMs: Math.round(totalDuration) }, \"capture completed\");\n await emitAttemptLog(input.recordLog, logger, \"info\", \"capture completed\", {\n durationMs: Math.round(totalDuration),\n });\n return { storyCount: stories.length, failedCount: blockingFailed.size };\n } catch (error) {\n const totalDuration = performance.now() - startTime;\n logger?.error({ durationMs: Math.round(totalDuration), err: error }, \"capture failed\");\n await emitAttemptLog(input.recordLog, logger, \"error\", \"capture failed\", {\n durationMs: Math.round(totalDuration),\n });\n await builds.setStatus(build.id, \"failed\").catch((markError: unknown) => {\n logger?.error({ err: markError }, \"failed to mark build failed after capture error\");\n });\n throw error;\n }\n}\n\n/**\n * Resolve the affected import paths for a build, or `null` for full capture\n * (affected capture disabled, or no computation was posted).\n */\nfunction affectedPathsFor(build: Build): string[] | null {\n if (!build.affectedOnly) {\n return null;\n }\n return BuildModel.affectedPaths(build);\n}\n\nasync function loadTarget(\n options: CaptureJobOptions,\n buildId: string,\n): Promise<{ build: Build; project: Project }> {\n const build = await new BuildModel(options.db, options.tables).get(buildId);\n if (!build) {\n throw new Error(`Build not found: ${buildId}`);\n }\n const project = await new ProjectModel(options.db, options.tables).get(build.projectId);\n if (!project) {\n throw new Error(`Project not found: ${build.projectId}`);\n }\n return { build, project };\n}\n"],"mappings":"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"}
|
|
@@ -4,14 +4,38 @@ import { Viewport } from "./adapter.mjs";
|
|
|
4
4
|
import { RenderedSnapshot } from "../adapter/capture-runner.mjs";
|
|
5
5
|
import { DatabaseAdapter } from "../adapter/database.mjs";
|
|
6
6
|
import { StorageAdapter } from "../adapter/storage.mjs";
|
|
7
|
+
import { SecretInput } from "../utils/encrypt.mjs";
|
|
7
8
|
import { Build } from "../schema/build.mjs";
|
|
9
|
+
import { BuildTables } from "../models/build.mjs";
|
|
10
|
+
import { AttemptLogRecorder } from "../models/capture-attempt.mjs";
|
|
8
11
|
import { Project } from "../schema/project.mjs";
|
|
12
|
+
import { BaselineTables } from "../models/baseline.mjs";
|
|
13
|
+
import { SnapshotTables } from "../models/snapshot.mjs";
|
|
14
|
+
import { InheritedStory } from "./affected.mjs";
|
|
9
15
|
import { Logger } from "pino";
|
|
10
16
|
//#region src/capture/pipeline.d.ts
|
|
17
|
+
/**
|
|
18
|
+
* Persist a completed capture run: write screenshots, diff against the branch
|
|
19
|
+
* baseline, create snapshots, and finalize the build.
|
|
20
|
+
*
|
|
21
|
+
* Rendering is performed by a pure `CaptureRunner`; this function owns only
|
|
22
|
+
* storage writes and record keeping, so it can run against any renderer.
|
|
23
|
+
*
|
|
24
|
+
* @param ctx - Capture context.
|
|
25
|
+
* @param renderFailedStoryIds - Story ids that the renderer could not capture
|
|
26
|
+
* (blocking failures). They force the build to `failed`.
|
|
27
|
+
* @param flakyFailedStoryIds - Story ids that failed but are marked flaky
|
|
28
|
+
* (non-blocking). They are logged as warnings and do not block the build.
|
|
29
|
+
*/
|
|
30
|
+
declare function persistCapture(ctx: CaptureContext, renderFailedStoryIds?: ReadonlySet<string>, flakyFailedStoryIds?: ReadonlySet<string>, a11yFailedStoryIds?: ReadonlySet<string>): Promise<void>;
|
|
31
|
+
/** Table handles required by the capture pipeline. */
|
|
32
|
+
type PipelineTables = BuildTables & BaselineTables & SnapshotTables;
|
|
11
33
|
/** Persistence inputs for a completed capture run. */
|
|
12
34
|
interface CaptureContext {
|
|
13
35
|
/** Database adapter. */
|
|
14
36
|
db: DatabaseAdapter;
|
|
37
|
+
/** Table handles. */
|
|
38
|
+
tables: PipelineTables;
|
|
15
39
|
/** Storage adapter. */
|
|
16
40
|
storage: StorageAdapter;
|
|
17
41
|
/** The project being captured. */
|
|
@@ -22,25 +46,15 @@ interface CaptureContext {
|
|
|
22
46
|
viewports: Viewport[];
|
|
23
47
|
/** Screenshot buffers produced by the capture renderer. */
|
|
24
48
|
captures: RenderedSnapshot[];
|
|
49
|
+
/** Stories inheriting their baseline without rendering (affected capture). */
|
|
50
|
+
inherited?: InheritedStory[];
|
|
25
51
|
/** Optional logger for capture diagnostics. */
|
|
26
52
|
logger?: Logger;
|
|
53
|
+
/** Optional per-attempt log recorder (mirrors logger lines into the DB). */
|
|
54
|
+
recordLog?: AttemptLogRecorder;
|
|
27
55
|
/** Server secret for decrypting webhook secrets at send time. */
|
|
28
|
-
secret?:
|
|
56
|
+
secret?: SecretInput;
|
|
29
57
|
}
|
|
30
|
-
/**
|
|
31
|
-
* Persist a completed capture run: write screenshots, diff against the branch
|
|
32
|
-
* baseline, create snapshots, and finalize the build.
|
|
33
|
-
*
|
|
34
|
-
* Rendering is performed by a pure `CaptureRunner`; this function owns only
|
|
35
|
-
* storage writes and record keeping, so it can run against any renderer.
|
|
36
|
-
*
|
|
37
|
-
* @param ctx - Capture context.
|
|
38
|
-
* @param renderFailedStoryIds - Story ids that the renderer could not capture
|
|
39
|
-
* (blocking failures). They force the build to `failed`.
|
|
40
|
-
* @param flakyFailedStoryIds - Story ids that failed but are marked flaky
|
|
41
|
-
* (non-blocking). They are logged as warnings and do not block the build.
|
|
42
|
-
*/
|
|
43
|
-
declare function persistCapture(ctx: CaptureContext, renderFailedStoryIds?: ReadonlySet<string>, flakyFailedStoryIds?: ReadonlySet<string>): Promise<void>;
|
|
44
58
|
//#endregion
|
|
45
|
-
export { CaptureContext, persistCapture };
|
|
59
|
+
export { CaptureContext, PipelineTables, persistCapture };
|
|
46
60
|
//# sourceMappingURL=pipeline.d.mts.map
|
|
@@ -2,11 +2,14 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { BuildModel } from "../models/build.mjs";
|
|
5
|
-
import {
|
|
6
|
-
import { DEFAULT_DIFF_OPTIONS } from "../diff/options.mjs";
|
|
5
|
+
import { emitAttemptLog } from "../models/capture-attempt.mjs";
|
|
7
6
|
import { diffPath, screenshotPath } from "../paths.mjs";
|
|
8
7
|
import { BaselineModel } from "../models/baseline.mjs";
|
|
8
|
+
import { NO_BASELINE } from "../models/baseline-status.mjs";
|
|
9
9
|
import { SnapshotModel } from "../models/snapshot.mjs";
|
|
10
|
+
import { diffStoredScreenshots, writeDiffOverlay } from "./diff-record.mjs";
|
|
11
|
+
import { assertScreenshotBuffer } from "./guards.mjs";
|
|
12
|
+
import { SIZING_DEFAULTS, infraHashFor } from "./sizing.mjs";
|
|
10
13
|
//#region src/capture/pipeline.ts
|
|
11
14
|
/**
|
|
12
15
|
* Persist a completed capture run: write screenshots, diff against the branch
|
|
@@ -21,30 +24,104 @@ import { SnapshotModel } from "../models/snapshot.mjs";
|
|
|
21
24
|
* @param flakyFailedStoryIds - Story ids that failed but are marked flaky
|
|
22
25
|
* (non-blocking). They are logged as warnings and do not block the build.
|
|
23
26
|
*/
|
|
24
|
-
async function persistCapture(ctx, renderFailedStoryIds = /* @__PURE__ */ new Set(), flakyFailedStoryIds = /* @__PURE__ */ new Set()) {
|
|
27
|
+
async function persistCapture(ctx, renderFailedStoryIds = /* @__PURE__ */ new Set(), flakyFailedStoryIds = /* @__PURE__ */ new Set(), a11yFailedStoryIds = /* @__PURE__ */ new Set()) {
|
|
25
28
|
const failedStoryIds = new Set(renderFailedStoryIds);
|
|
26
29
|
const flakyIds = new Set(flakyFailedStoryIds);
|
|
30
|
+
const a11yIds = new Set(a11yFailedStoryIds);
|
|
27
31
|
await Promise.all(ctx.captures.map(async (capture) => {
|
|
28
32
|
try {
|
|
29
33
|
await persistSnapshot(ctx, capture);
|
|
30
34
|
} catch (error) {
|
|
31
|
-
if (flakyIds.has(capture.story.id)) {
|
|
32
|
-
flakyIds.add(capture.story.id);
|
|
35
|
+
if (flakyIds.has(capture.story.id) || a11yIds.has(capture.story.id)) {
|
|
36
|
+
if (flakyIds.has(capture.story.id)) flakyIds.add(capture.story.id);
|
|
37
|
+
if (a11yIds.has(capture.story.id)) a11yIds.add(capture.story.id);
|
|
33
38
|
ctx.logger?.warn({
|
|
34
39
|
storyId: capture.story.id,
|
|
35
40
|
viewport: capture.viewportName,
|
|
36
41
|
err: error
|
|
37
|
-
}, "capture failed for flaky story (non-blocking)");
|
|
42
|
+
}, "capture failed for flaky/a11y story (non-blocking)");
|
|
43
|
+
await emitAttemptLog(ctx.recordLog, ctx.logger, "warn", "capture failed for flaky/a11y story (non-blocking)", {
|
|
44
|
+
storyId: capture.story.id,
|
|
45
|
+
viewport: capture.viewportName
|
|
46
|
+
});
|
|
38
47
|
} else failedStoryIds.add(capture.story.id);
|
|
39
48
|
ctx.logger?.error({
|
|
40
49
|
storyId: capture.story.id,
|
|
41
50
|
viewport: capture.viewportName,
|
|
42
51
|
err: error
|
|
43
52
|
}, "capture failed for story");
|
|
53
|
+
await emitAttemptLog(ctx.recordLog, ctx.logger, "error", "capture failed for story", {
|
|
54
|
+
storyId: capture.story.id,
|
|
55
|
+
viewport: capture.viewportName
|
|
56
|
+
});
|
|
44
57
|
}
|
|
45
58
|
}));
|
|
46
|
-
for (const id of flakyIds) if (!failedStoryIds.has(id))
|
|
47
|
-
|
|
59
|
+
for (const id of flakyIds) if (!failedStoryIds.has(id)) {
|
|
60
|
+
ctx.logger?.warn({ storyId: id }, "flaky story failed (non-blocking)");
|
|
61
|
+
await emitAttemptLog(ctx.recordLog, ctx.logger, "warn", "flaky story failed (non-blocking)", { storyId: id });
|
|
62
|
+
}
|
|
63
|
+
for (const id of a11yIds) if (!failedStoryIds.has(id) && !flakyIds.has(id)) {
|
|
64
|
+
ctx.logger?.warn({ storyId: id }, "a11y violations found (non-blocking)");
|
|
65
|
+
await emitAttemptLog(ctx.recordLog, ctx.logger, "warn", "a11y violations found (non-blocking)", { storyId: id });
|
|
66
|
+
}
|
|
67
|
+
for (const id of await persistInherited(ctx)) failedStoryIds.add(id);
|
|
68
|
+
await finalize(ctx, allStoryIds(ctx), failedStoryIds, flakyIds, a11yIds);
|
|
69
|
+
}
|
|
70
|
+
/** Persist one inherited snapshot; returns the story id on failure. */
|
|
71
|
+
async function tryPersistInherited(ctx, inherited) {
|
|
72
|
+
try {
|
|
73
|
+
await persistInheritedSnapshot(ctx, inherited);
|
|
74
|
+
return null;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
ctx.logger?.error({
|
|
77
|
+
storyId: inherited.story.id,
|
|
78
|
+
viewport: inherited.viewport.name,
|
|
79
|
+
err: error
|
|
80
|
+
}, "inherit failed for story");
|
|
81
|
+
await emitAttemptLog(ctx.recordLog, ctx.logger, "error", "inherit failed for story", {
|
|
82
|
+
storyId: inherited.story.id,
|
|
83
|
+
viewport: inherited.viewport.name
|
|
84
|
+
});
|
|
85
|
+
return inherited.story.id;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Persist inherited snapshots; a failure marks the story failed. */
|
|
89
|
+
async function persistInherited(ctx) {
|
|
90
|
+
const outcomes = await Promise.all((ctx.inherited ?? []).map(async (inherited) => await tryPersistInherited(ctx, inherited)));
|
|
91
|
+
return new Set(outcomes.filter((id) => id !== null));
|
|
92
|
+
}
|
|
93
|
+
/** Create an unchanged snapshot reusing the baseline screenshot. */
|
|
94
|
+
async function persistInheritedSnapshot(ctx, inherited) {
|
|
95
|
+
const infraHash = infraHashFor(ctx.project.browser ?? "chromium", ctx.viewports, SIZING_DEFAULTS);
|
|
96
|
+
const snapshots = new SnapshotModel(ctx.db, ctx.tables);
|
|
97
|
+
const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {
|
|
98
|
+
storyId: inherited.story.id,
|
|
99
|
+
storyName: inherited.story.name,
|
|
100
|
+
storyTitle: inherited.story.title,
|
|
101
|
+
storyImportPath: inherited.story.importPath ?? "",
|
|
102
|
+
viewportName: inherited.viewport.name,
|
|
103
|
+
viewportWidth: inherited.viewport.width,
|
|
104
|
+
viewportHeight: inherited.viewport.height,
|
|
105
|
+
screenshotPath: inherited.baseline.screenshotPath,
|
|
106
|
+
infraHash,
|
|
107
|
+
inherited: true,
|
|
108
|
+
baselineId: inherited.baseline.id,
|
|
109
|
+
baselineVersion: inherited.baseline.updatedAt
|
|
110
|
+
});
|
|
111
|
+
await snapshots.update(snapshot.id, {
|
|
112
|
+
status: "unchanged",
|
|
113
|
+
diffPixels: 0,
|
|
114
|
+
diffRatio: 0,
|
|
115
|
+
diffPassed: true,
|
|
116
|
+
diffPath: null,
|
|
117
|
+
infraHash
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Every story covered by a capture run, rendered or inherited. */
|
|
121
|
+
function allStoryIds(ctx) {
|
|
122
|
+
const ids = new Set(ctx.captures.map((capture) => capture.story.id));
|
|
123
|
+
for (const inherited of ctx.inherited ?? []) ids.add(inherited.story.id);
|
|
124
|
+
return ids;
|
|
48
125
|
}
|
|
49
126
|
function viewportByName(ctx, name) {
|
|
50
127
|
return ctx.viewports.find((v) => v.name === name) ?? {
|
|
@@ -54,7 +131,8 @@ function viewportByName(ctx, name) {
|
|
|
54
131
|
};
|
|
55
132
|
}
|
|
56
133
|
async function persistSnapshot(ctx, capture) {
|
|
57
|
-
|
|
134
|
+
assertScreenshotBuffer(capture.screenshot);
|
|
135
|
+
const viewport = capture.viewport ?? viewportByName(ctx, capture.viewportName);
|
|
58
136
|
const screenshot = screenshotPath(ctx.project.id, ctx.build.id, capture.story.id, capture.viewportName);
|
|
59
137
|
await ctx.storage.write(screenshot, capture.screenshot);
|
|
60
138
|
const baseline = await resolveBaseline(ctx, capture.story.id, capture.viewportName);
|
|
@@ -62,14 +140,15 @@ async function persistSnapshot(ctx, capture) {
|
|
|
62
140
|
await createWithoutBaseline(ctx, capture, viewport, screenshot);
|
|
63
141
|
return;
|
|
64
142
|
}
|
|
65
|
-
await createWithBaseline(ctx, capture, viewport, screenshot, baseline
|
|
143
|
+
await createWithBaseline(ctx, capture, viewport, screenshot, baseline);
|
|
66
144
|
}
|
|
67
145
|
async function resolveBaseline(ctx, storyId, viewport) {
|
|
68
|
-
return await new BaselineModel(ctx.db, ctx.storage, ctx.secret).resolve(ctx.project.id, storyId, viewport, ctx.build.gitBranch, ctx.project.gitDefaultBranch);
|
|
146
|
+
return await new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret).resolve(ctx.project.id, storyId, viewport, ctx.build.gitBranch, ctx.project.gitDefaultBranch);
|
|
69
147
|
}
|
|
70
148
|
async function createWithoutBaseline(ctx, capture, viewport, screenshot) {
|
|
71
|
-
const snapshots = new SnapshotModel(ctx.db);
|
|
149
|
+
const snapshots = new SnapshotModel(ctx.db, ctx.tables);
|
|
72
150
|
const status = ctx.build.isDefault ? "approved" : "new";
|
|
151
|
+
const infraHash = infraHashFor(ctx.project.browser ?? "chromium", ctx.viewports, SIZING_DEFAULTS);
|
|
73
152
|
const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {
|
|
74
153
|
storyId: capture.story.id,
|
|
75
154
|
storyName: capture.story.name,
|
|
@@ -78,21 +157,17 @@ async function createWithoutBaseline(ctx, capture, viewport, screenshot) {
|
|
|
78
157
|
viewportName: capture.viewportName,
|
|
79
158
|
viewportWidth: viewport.width,
|
|
80
159
|
viewportHeight: viewport.height,
|
|
81
|
-
screenshotPath: screenshot
|
|
160
|
+
screenshotPath: screenshot,
|
|
161
|
+
infraHash,
|
|
162
|
+
baselineVersion: NO_BASELINE
|
|
82
163
|
});
|
|
83
164
|
await snapshots.setStatus(snapshot.id, status);
|
|
84
|
-
if (ctx.build.isDefault) await new BaselineModel(ctx.db, ctx.storage, ctx.secret).upsert(ctx.project.id, capture.story.id, capture.viewportName, ctx.build.gitBranch, snapshot.id, screenshot);
|
|
165
|
+
if (ctx.build.isDefault) await new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret).upsert(ctx.project.id, capture.story.id, capture.viewportName, ctx.build.gitBranch, snapshot.id, screenshot, infraHash);
|
|
85
166
|
}
|
|
86
|
-
async function createWithBaseline(ctx, capture, viewport, screenshot,
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
...DEFAULT_DIFF_OPTIONS,
|
|
91
|
-
pixelThreshold: ctx.project.pixelThreshold,
|
|
92
|
-
maxDiffRatio: ctx.project.maxDiffRatio
|
|
93
|
-
};
|
|
94
|
-
const result = diffImages(previous, current, options);
|
|
95
|
-
const snapshots = new SnapshotModel(ctx.db);
|
|
167
|
+
async function createWithBaseline(ctx, capture, viewport, screenshot, baseline) {
|
|
168
|
+
const result = await diffStoredScreenshots(ctx.storage, ctx.project, baseline.screenshotPath, screenshot, capture.story.parameters?.diffThreshold);
|
|
169
|
+
const infraHash = infraHashFor(ctx.project.browser ?? "chromium", ctx.viewports, SIZING_DEFAULTS);
|
|
170
|
+
const snapshots = new SnapshotModel(ctx.db, ctx.tables);
|
|
96
171
|
const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {
|
|
97
172
|
storyId: capture.story.id,
|
|
98
173
|
storyName: capture.story.name,
|
|
@@ -101,31 +176,60 @@ async function createWithBaseline(ctx, capture, viewport, screenshot, baselinePa
|
|
|
101
176
|
viewportName: capture.viewportName,
|
|
102
177
|
viewportWidth: viewport.width,
|
|
103
178
|
viewportHeight: viewport.height,
|
|
104
|
-
screenshotPath: screenshot
|
|
179
|
+
screenshotPath: screenshot,
|
|
180
|
+
infraHash,
|
|
181
|
+
baselineId: baseline.id,
|
|
182
|
+
baselineVersion: baseline.updatedAt
|
|
105
183
|
});
|
|
106
|
-
const
|
|
184
|
+
const shouldAutomigrate = result.sizeChanged && !result.passed && ctx.project.automigrate && baseline.infraHash !== null && baseline.infraHash !== infraHash;
|
|
185
|
+
const status = shouldAutomigrate ? "unchanged" : diffStatus(result.passed, ctx.build.isDefault);
|
|
107
186
|
const diff = diffPath(ctx.project.id, ctx.build.id, capture.story.id, capture.viewportName);
|
|
108
|
-
|
|
187
|
+
const passed = shouldAutomigrate ? true : result.passed;
|
|
188
|
+
const pixels = shouldAutomigrate ? 0 : result.diffPixels;
|
|
189
|
+
const ratio = shouldAutomigrate ? 0 : result.diffRatio;
|
|
190
|
+
const overlay = await writeDiffOverlay(ctx.storage, diff, result, passed);
|
|
109
191
|
await snapshots.update(snapshot.id, {
|
|
110
192
|
status,
|
|
111
|
-
diffPath:
|
|
112
|
-
diffPixels:
|
|
113
|
-
diffRatio:
|
|
114
|
-
diffPassed:
|
|
193
|
+
diffPath: overlay,
|
|
194
|
+
diffPixels: pixels,
|
|
195
|
+
diffRatio: ratio,
|
|
196
|
+
diffPassed: passed,
|
|
197
|
+
infraHash
|
|
115
198
|
});
|
|
199
|
+
if (ctx.build.isDefault && (shouldAutomigrate || !passed)) await upsertDefaultBaseline(ctx, snapshot.id, capture, screenshot, infraHash);
|
|
200
|
+
}
|
|
201
|
+
/** Default-branch builds are authoritative: the captured screenshot becomes the baseline. */
|
|
202
|
+
async function upsertDefaultBaseline(ctx, snapshotId, capture, screenshot, infraHash) {
|
|
203
|
+
await new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret).upsert(ctx.project.id, capture.story.id, capture.viewportName, ctx.build.gitBranch, snapshotId, screenshot, infraHash);
|
|
116
204
|
}
|
|
117
|
-
|
|
118
|
-
|
|
205
|
+
/** Snapshot status after diffing: default-branch changes are auto-approved, not queued for review. */
|
|
206
|
+
function diffStatus(passed, isDefault) {
|
|
207
|
+
if (passed) return "unchanged";
|
|
208
|
+
return isDefault ? "approved" : "changed";
|
|
209
|
+
}
|
|
210
|
+
/** A failed capture fails the build; default-branch builds otherwise auto-approve. */
|
|
211
|
+
function finalBuildStatus(isDefault, failedCount, hasCaptures, build) {
|
|
212
|
+
if (failedCount > 0) return "failed";
|
|
213
|
+
if (isDefault || hasCaptures && build.changedCount === 0) return "approved";
|
|
214
|
+
return "reviewing";
|
|
215
|
+
}
|
|
216
|
+
async function finalize(ctx, storyIds, failedStoryIds, flakyFailedStoryIds = /* @__PURE__ */ new Set(), a11yFailedStoryIds = /* @__PURE__ */ new Set()) {
|
|
217
|
+
const builds = new BuildModel(ctx.db, ctx.tables);
|
|
119
218
|
const build = await builds.updateCounts(ctx.build.id);
|
|
120
219
|
const hasCaptures = storyIds.size > 0;
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
220
|
+
const status = finalBuildStatus(ctx.build.isDefault, failedStoryIds.size, hasCaptures, build);
|
|
221
|
+
if (flakyFailedStoryIds.size > 0 && failedStoryIds.size === 0) {
|
|
222
|
+
ctx.logger?.warn({ flakyStoryIds: [...flakyFailedStoryIds] }, "flaky stories failed (non-blocking)");
|
|
223
|
+
await emitAttemptLog(ctx.recordLog, ctx.logger, "warn", "flaky stories failed (non-blocking)", { flakyStoryIds: [...flakyFailedStoryIds] });
|
|
224
|
+
}
|
|
225
|
+
if (a11yFailedStoryIds.size > 0 && failedStoryIds.size === 0) {
|
|
226
|
+
ctx.logger?.warn({ a11yStoryIds: [...a11yFailedStoryIds] }, "a11y violations found (non-blocking)");
|
|
227
|
+
await emitAttemptLog(ctx.recordLog, ctx.logger, "warn", "a11y violations found (non-blocking)", { a11yStoryIds: [...a11yFailedStoryIds] });
|
|
228
|
+
}
|
|
125
229
|
await builds.setStatus(ctx.build.id, status);
|
|
126
|
-
if (ctx.build.isDefault && hasCaptures) await new BaselineModel(ctx.db, ctx.storage, ctx.secret).removeOrphans(ctx.project.id, new Set(storyIds));
|
|
230
|
+
if (ctx.build.isDefault && hasCaptures) await new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret).removeOrphans(ctx.project.id, new Set(storyIds));
|
|
127
231
|
}
|
|
128
232
|
//#endregion
|
|
129
|
-
export { persistCapture };
|
|
233
|
+
export { diffStatus, persistCapture };
|
|
130
234
|
|
|
131
235
|
//# sourceMappingURL=pipeline.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline.mjs","names":[],"sources":["../../src/capture/pipeline.ts"],"sourcesContent":["import type { Logger } from \"pino\";\nimport type { RenderedSnapshot } from \"../adapters/capture-runner.ts\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { diffImages } from \"../diff/engine.ts\";\nimport { DEFAULT_DIFF_OPTIONS } from \"../diff/options.ts\";\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport type { Baseline } from \"../schema/baseline.ts\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport type { BuildStatus } from \"../types.ts\";\nimport { diffPath, screenshotPath } from \"../utils/paths.ts\";\nimport type { Viewport } from \"./adapter.ts\";\n\n/** Persistence inputs for a completed capture run. */\nexport interface CaptureContext {\n /** Database adapter. */\n db: DatabaseAdapter;\n /** Storage adapter. */\n storage: StorageAdapter;\n /** The project being captured. */\n project: Project;\n /** The build being captured. */\n build: Build;\n /** Viewports at which stories were captured. */\n viewports: Viewport[];\n /** Screenshot buffers produced by the capture renderer. */\n captures: RenderedSnapshot[];\n /** Optional logger for capture diagnostics. */\n logger?: Logger;\n /** Server secret for decrypting webhook secrets at send time. */\n secret?: string | undefined;\n}\n\n/**\n * Persist a completed capture run: write screenshots, diff against the branch\n * baseline, create snapshots, and finalize the build.\n *\n * Rendering is performed by a pure `CaptureRunner`; this function owns only\n * storage writes and record keeping, so it can run against any renderer.\n *\n * @param ctx - Capture context.\n * @param renderFailedStoryIds - Story ids that the renderer could not capture\n * (blocking failures). They force the build to `failed`.\n * @param flakyFailedStoryIds - Story ids that failed but are marked flaky\n * (non-blocking). They are logged as warnings and do not block the build.\n */\nexport async function persistCapture(\n ctx: CaptureContext,\n renderFailedStoryIds: ReadonlySet<string> = new Set(),\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const failedStoryIds = new Set(renderFailedStoryIds);\n const flakyIds = new Set(flakyFailedStoryIds);\n await Promise.all(\n ctx.captures.map(async (capture) => {\n try {\n await persistSnapshot(ctx, capture);\n } catch (error) {\n // A failure in one story/viewport must not abort the other persists.\n // Flaky stories remain non-blocking even on persist failure.\n if (flakyIds.has(capture.story.id)) {\n flakyIds.add(capture.story.id);\n ctx.logger?.warn(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for flaky story (non-blocking)\",\n );\n } else {\n failedStoryIds.add(capture.story.id);\n }\n ctx.logger?.error(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for story\",\n );\n }\n }),\n );\n // Also log flaky render failures as warnings\n for (const id of flakyIds) {\n if (!failedStoryIds.has(id)) {\n ctx.logger?.warn({ storyId: id }, \"flaky story failed (non-blocking)\");\n }\n }\n await finalize(ctx, new Set(ctx.captures.map((c) => c.story.id)), failedStoryIds, flakyIds);\n}\n\nfunction viewportByName(ctx: CaptureContext, name: string): Viewport {\n return ctx.viewports.find((v) => v.name === name) ?? { name, width: 0, height: 0 };\n}\n\nasync function persistSnapshot(ctx: CaptureContext, capture: RenderedSnapshot): Promise<void> {\n const viewport = viewportByName(ctx, capture.viewportName);\n const screenshot = screenshotPath(\n ctx.project.id,\n ctx.build.id,\n capture.story.id,\n capture.viewportName,\n );\n await ctx.storage.write(screenshot, capture.screenshot);\n\n const baseline = await resolveBaseline(ctx, capture.story.id, capture.viewportName);\n if (!baseline) {\n await createWithoutBaseline(ctx, capture, viewport, screenshot);\n return;\n }\n await createWithBaseline(ctx, capture, viewport, screenshot, baseline.screenshotPath);\n}\n\nasync function resolveBaseline(\n ctx: CaptureContext,\n storyId: string,\n viewport: string,\n): Promise<Baseline | null> {\n const baselines = new BaselineModel(ctx.db, ctx.storage, ctx.secret);\n return await baselines.resolve(\n ctx.project.id,\n storyId,\n viewport,\n ctx.build.gitBranch,\n ctx.project.gitDefaultBranch,\n );\n}\n\nasync function createWithoutBaseline(\n ctx: CaptureContext,\n capture: RenderedSnapshot,\n viewport: Viewport,\n screenshot: string,\n): Promise<void> {\n const snapshots = new SnapshotModel(ctx.db);\n const status = ctx.build.isDefault ? \"approved\" : \"new\";\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n });\n await snapshots.setStatus(snapshot.id, status);\n\n if (ctx.build.isDefault) {\n const baselines = new BaselineModel(ctx.db, ctx.storage, ctx.secret);\n await baselines.upsert(\n ctx.project.id,\n capture.story.id,\n capture.viewportName,\n ctx.build.gitBranch,\n snapshot.id,\n screenshot,\n );\n }\n}\n\nasync function createWithBaseline(\n ctx: CaptureContext,\n capture: RenderedSnapshot,\n viewport: Viewport,\n screenshot: string,\n baselinePath: string,\n): Promise<void> {\n const current = await ctx.storage.read(screenshot);\n const previous = await ctx.storage.read(baselinePath);\n const options = {\n ...DEFAULT_DIFF_OPTIONS,\n pixelThreshold: ctx.project.pixelThreshold,\n maxDiffRatio: ctx.project.maxDiffRatio,\n };\n const result = diffImages(previous, current, options);\n\n const snapshots = new SnapshotModel(ctx.db);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n });\n\n const status = result.passed ? \"unchanged\" : \"changed\";\n const diff = diffPath(ctx.project.id, ctx.build.id, capture.story.id, capture.viewportName);\n if (!result.passed && result.diffImage) {\n await ctx.storage.write(diff, result.diffImage);\n }\n await snapshots.update(snapshot.id, {\n status,\n diffPath: !result.passed && result.diffImage ? diff : null,\n diffPixels: result.diffPixels,\n diffRatio: result.diffRatio,\n diffPassed: result.passed,\n });\n}\n\nasync function finalize(\n ctx: CaptureContext,\n storyIds: ReadonlySet<string>,\n failedStoryIds: ReadonlySet<string>,\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const builds = new BuildModel(ctx.db);\n const build = await builds.updateCounts(ctx.build.id);\n const hasCaptures = storyIds.size > 0;\n let status: BuildStatus = \"reviewing\";\n if (failedStoryIds.size > 0) {\n status = \"failed\";\n } else if (hasCaptures && build.changedCount === 0) {\n status = \"approved\";\n }\n // Flaky failures do not block: log warning if any flaky stories failed\n if (flakyFailedStoryIds.size > 0 && failedStoryIds.size === 0) {\n ctx.logger?.warn(\n { flakyStoryIds: [...flakyFailedStoryIds] },\n \"flaky stories failed (non-blocking)\",\n );\n }\n await builds.setStatus(ctx.build.id, status);\n\n if (ctx.build.isDefault && hasCaptures) {\n const baselines = new BaselineModel(ctx.db, ctx.storage, ctx.secret);\n await baselines.removeOrphans(ctx.project.id, new Set(storyIds));\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"pipeline.mjs","names":[],"sources":["../../src/capture/pipeline.ts"],"sourcesContent":["import type { Logger } from \"pino\";\nimport type { RenderedSnapshot } from \"../adapters/capture-runner.ts\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { NO_BASELINE } from \"../models/baseline-status.ts\";\nimport { BaselineModel, type BaselineTables } from \"../models/baseline.ts\";\nimport { BuildModel, type BuildTables } from \"../models/build.ts\";\nimport { emitAttemptLog, type AttemptLogRecorder } from \"../models/capture-attempt.ts\";\nimport { SnapshotModel, type SnapshotTables } from \"../models/snapshot.ts\";\nimport type { Baseline } from \"../schema/baseline.ts\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport type { BuildStatus, SnapshotStatus } from \"../types.ts\";\nimport type { SecretInput } from \"../utils/encrypt.ts\";\nimport { diffPath, screenshotPath } from \"../utils/paths.ts\";\nimport type { Viewport } from \"./adapter.ts\";\nimport type { InheritedStory } from \"./affected.ts\";\nimport { diffStoredScreenshots, writeDiffOverlay } from \"./diff-record.ts\";\nimport { assertScreenshotBuffer } from \"./guards.ts\";\nimport { infraHashFor, SIZING_DEFAULTS } from \"./sizing.ts\";\n\n/**\n * Persist a completed capture run: write screenshots, diff against the branch\n * baseline, create snapshots, and finalize the build.\n *\n * Rendering is performed by a pure `CaptureRunner`; this function owns only\n * storage writes and record keeping, so it can run against any renderer.\n *\n * @param ctx - Capture context.\n * @param renderFailedStoryIds - Story ids that the renderer could not capture\n * (blocking failures). They force the build to `failed`.\n * @param flakyFailedStoryIds - Story ids that failed but are marked flaky\n * (non-blocking). They are logged as warnings and do not block the build.\n */\nexport async function persistCapture(\n ctx: CaptureContext,\n renderFailedStoryIds: ReadonlySet<string> = new Set(),\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n a11yFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const failedStoryIds = new Set(renderFailedStoryIds);\n const flakyIds = new Set(flakyFailedStoryIds);\n const a11yIds = new Set(a11yFailedStoryIds);\n await Promise.all(\n ctx.captures.map(async (capture) => {\n try {\n await persistSnapshot(ctx, capture);\n } catch (error) {\n // A failure in one story/viewport must not abort the other persists.\n // Flaky and a11y stories remain non-blocking even on persist failure.\n if (flakyIds.has(capture.story.id) || a11yIds.has(capture.story.id)) {\n if (flakyIds.has(capture.story.id)) flakyIds.add(capture.story.id);\n if (a11yIds.has(capture.story.id)) a11yIds.add(capture.story.id);\n ctx.logger?.warn(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for flaky/a11y story (non-blocking)\",\n );\n await emitAttemptLog(\n ctx.recordLog,\n ctx.logger,\n \"warn\",\n \"capture failed for flaky/a11y story (non-blocking)\",\n {\n storyId: capture.story.id,\n viewport: capture.viewportName,\n },\n );\n } else {\n failedStoryIds.add(capture.story.id);\n }\n ctx.logger?.error(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for story\",\n );\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"error\", \"capture failed for story\", {\n storyId: capture.story.id,\n viewport: capture.viewportName,\n });\n }\n }),\n );\n // Also log flaky and a11y render failures as warnings\n for (const id of flakyIds) {\n if (!failedStoryIds.has(id)) {\n ctx.logger?.warn({ storyId: id }, \"flaky story failed (non-blocking)\");\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"warn\", \"flaky story failed (non-blocking)\", {\n storyId: id,\n });\n }\n }\n for (const id of a11yIds) {\n if (!failedStoryIds.has(id) && !flakyIds.has(id)) {\n ctx.logger?.warn({ storyId: id }, \"a11y violations found (non-blocking)\");\n await emitAttemptLog(\n ctx.recordLog,\n ctx.logger,\n \"warn\",\n \"a11y violations found (non-blocking)\",\n {\n storyId: id,\n },\n );\n }\n }\n for (const id of await persistInherited(ctx)) {\n failedStoryIds.add(id);\n }\n await finalize(ctx, allStoryIds(ctx), failedStoryIds, flakyIds, a11yIds);\n}\n\n/** Persist one inherited snapshot; returns the story id on failure. */\nasync function tryPersistInherited(\n ctx: CaptureContext,\n inherited: InheritedStory,\n): Promise<string | null> {\n try {\n await persistInheritedSnapshot(ctx, inherited);\n return null;\n } catch (error) {\n ctx.logger?.error(\n { storyId: inherited.story.id, viewport: inherited.viewport.name, err: error },\n \"inherit failed for story\",\n );\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"error\", \"inherit failed for story\", {\n storyId: inherited.story.id,\n viewport: inherited.viewport.name,\n });\n return inherited.story.id;\n }\n}\n\n/** Persist inherited snapshots; a failure marks the story failed. */\nasync function persistInherited(ctx: CaptureContext): Promise<ReadonlySet<string>> {\n const outcomes = await Promise.all(\n (ctx.inherited ?? []).map(async (inherited) => await tryPersistInherited(ctx, inherited)),\n );\n return new Set(outcomes.filter((id): id is string => id !== null));\n}\n\n/** Create an unchanged snapshot reusing the baseline screenshot. */\nasync function persistInheritedSnapshot(\n ctx: CaptureContext,\n inherited: InheritedStory,\n): Promise<void> {\n const infraHash = infraHashFor(ctx.project.browser ?? \"chromium\", ctx.viewports, SIZING_DEFAULTS);\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n storyId: inherited.story.id,\n storyName: inherited.story.name,\n storyTitle: inherited.story.title,\n storyImportPath: inherited.story.importPath ?? \"\",\n viewportName: inherited.viewport.name,\n viewportWidth: inherited.viewport.width,\n viewportHeight: inherited.viewport.height,\n screenshotPath: inherited.baseline.screenshotPath,\n infraHash,\n inherited: true,\n baselineId: inherited.baseline.id,\n baselineVersion: inherited.baseline.updatedAt,\n });\n await snapshots.update(snapshot.id, {\n status: \"unchanged\",\n diffPixels: 0,\n diffRatio: 0,\n diffPassed: true,\n diffPath: null,\n infraHash,\n });\n}\n\n/** Every story covered by a capture run, rendered or inherited. */\nfunction allStoryIds(ctx: CaptureContext): ReadonlySet<string> {\n const ids = new Set(ctx.captures.map((capture) => capture.story.id));\n for (const inherited of ctx.inherited ?? []) {\n ids.add(inherited.story.id);\n }\n return ids;\n}\n\n/** Table handles required by the capture pipeline. */\nexport type PipelineTables = BuildTables & BaselineTables & SnapshotTables;\n\n/** Persistence inputs for a completed capture run. */\nexport interface CaptureContext {\n /** Database adapter. */\n db: DatabaseAdapter;\n /** Table handles. */\n tables: PipelineTables;\n /** Storage adapter. */\n storage: StorageAdapter;\n /** The project being captured. */\n project: Project;\n /** The build being captured. */\n build: Build;\n /** Viewports at which stories were captured. */\n viewports: Viewport[];\n /** Screenshot buffers produced by the capture renderer. */\n captures: RenderedSnapshot[];\n /** Stories inheriting their baseline without rendering (affected capture). */\n inherited?: InheritedStory[];\n /** Optional logger for capture diagnostics. */\n logger?: Logger;\n /** Optional per-attempt log recorder (mirrors logger lines into the DB). */\n recordLog?: AttemptLogRecorder;\n /** Server secret for decrypting webhook secrets at send time. */\n secret?: SecretInput;\n}\n\nfunction viewportByName(ctx: CaptureContext, name: string): Viewport {\n return ctx.viewports.find((v) => v.name === name) ?? { name, width: 0, height: 0 };\n}\n\nasync function persistSnapshot(ctx: CaptureContext, capture: RenderedSnapshot): Promise<void> {\n assertScreenshotBuffer(capture.screenshot);\n const viewport = capture.viewport ?? viewportByName(ctx, capture.viewportName);\n const screenshot = screenshotPath(\n ctx.project.id,\n ctx.build.id,\n capture.story.id,\n capture.viewportName,\n );\n await ctx.storage.write(screenshot, capture.screenshot);\n\n const baseline = await resolveBaseline(ctx, capture.story.id, capture.viewportName);\n if (!baseline) {\n await createWithoutBaseline(ctx, capture, viewport, screenshot);\n return;\n }\n await createWithBaseline(ctx, capture, viewport, screenshot, baseline);\n}\n\nasync function resolveBaseline(\n ctx: CaptureContext,\n storyId: string,\n viewport: string,\n): Promise<Baseline | null> {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n return await baselines.resolve(\n ctx.project.id,\n storyId,\n viewport,\n ctx.build.gitBranch,\n ctx.project.gitDefaultBranch,\n );\n}\n\nasync function createWithoutBaseline(\n ctx: CaptureContext,\n capture: RenderedSnapshot,\n viewport: Viewport,\n screenshot: string,\n): Promise<void> {\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const status = ctx.build.isDefault ? \"approved\" : \"new\";\n const infraHash = infraHashFor(ctx.project.browser ?? \"chromium\", ctx.viewports, SIZING_DEFAULTS);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n infraHash,\n baselineVersion: NO_BASELINE,\n });\n await snapshots.setStatus(snapshot.id, status);\n\n if (ctx.build.isDefault) {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n await baselines.upsert(\n ctx.project.id,\n capture.story.id,\n capture.viewportName,\n ctx.build.gitBranch,\n snapshot.id,\n screenshot,\n infraHash,\n );\n }\n}\n\nasync function createWithBaseline(\n ctx: CaptureContext,\n capture: RenderedSnapshot,\n viewport: Viewport,\n screenshot: string,\n baseline: Baseline,\n): Promise<void> {\n const result = await diffStoredScreenshots(\n ctx.storage,\n ctx.project,\n baseline.screenshotPath,\n screenshot,\n capture.story.parameters?.diffThreshold,\n );\n\n const infraHash = infraHashFor(ctx.project.browser ?? \"chromium\", ctx.viewports, SIZING_DEFAULTS);\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n infraHash,\n baselineId: baseline.id,\n baselineVersion: baseline.updatedAt,\n });\n\n // Automigrate: infra-induced size change with project.automigrate enabled\n const shouldAutomigrate =\n result.sizeChanged &&\n !result.passed &&\n (ctx.project as unknown as { automigrate?: boolean }).automigrate &&\n baseline.infraHash !== null &&\n baseline.infraHash !== infraHash;\n\n const status = shouldAutomigrate ? \"unchanged\" : diffStatus(result.passed, ctx.build.isDefault);\n const diff = diffPath(ctx.project.id, ctx.build.id, capture.story.id, capture.viewportName);\n const passed = shouldAutomigrate ? true : result.passed;\n const pixels = shouldAutomigrate ? 0 : result.diffPixels;\n const ratio = shouldAutomigrate ? 0 : result.diffRatio;\n const overlay = await writeDiffOverlay(ctx.storage, diff, result, passed);\n await snapshots.update(snapshot.id, {\n status,\n diffPath: overlay,\n diffPixels: pixels,\n diffRatio: ratio,\n diffPassed: passed,\n infraHash,\n });\n if (ctx.build.isDefault && (shouldAutomigrate || !passed)) {\n await upsertDefaultBaseline(ctx, snapshot.id, capture, screenshot, infraHash);\n }\n}\n\n/** Default-branch builds are authoritative: the captured screenshot becomes the baseline. */\nasync function upsertDefaultBaseline(\n ctx: CaptureContext,\n snapshotId: string,\n capture: RenderedSnapshot,\n screenshot: string,\n infraHash: string,\n): Promise<void> {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n await baselines.upsert(\n ctx.project.id,\n capture.story.id,\n capture.viewportName,\n ctx.build.gitBranch,\n snapshotId,\n screenshot,\n infraHash,\n );\n}\n\n/** Snapshot status after diffing: default-branch changes are auto-approved, not queued for review. */\nexport function diffStatus(passed: boolean, isDefault: boolean): SnapshotStatus {\n if (passed) {\n return \"unchanged\";\n }\n return isDefault ? \"approved\" : \"changed\";\n}\n\n/** A failed capture fails the build; default-branch builds otherwise auto-approve. */\nfunction finalBuildStatus(\n isDefault: boolean,\n failedCount: number,\n hasCaptures: boolean,\n build: { changedCount: number },\n): BuildStatus {\n if (failedCount > 0) {\n return \"failed\";\n }\n if (isDefault || (hasCaptures && build.changedCount === 0)) {\n return \"approved\";\n }\n return \"reviewing\";\n}\n\nasync function finalize(\n ctx: CaptureContext,\n storyIds: ReadonlySet<string>,\n failedStoryIds: ReadonlySet<string>,\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n a11yFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const builds = new BuildModel(ctx.db, ctx.tables);\n const build = await builds.updateCounts(ctx.build.id);\n const hasCaptures = storyIds.size > 0;\n const status = finalBuildStatus(ctx.build.isDefault, failedStoryIds.size, hasCaptures, build);\n // Flaky and a11y failures do not block: log warning if any failed non-blocking\n if (flakyFailedStoryIds.size > 0 && failedStoryIds.size === 0) {\n ctx.logger?.warn(\n { flakyStoryIds: [...flakyFailedStoryIds] },\n \"flaky stories failed (non-blocking)\",\n );\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"warn\", \"flaky stories failed (non-blocking)\", {\n flakyStoryIds: [...flakyFailedStoryIds],\n });\n }\n if (a11yFailedStoryIds.size > 0 && failedStoryIds.size === 0) {\n ctx.logger?.warn(\n { a11yStoryIds: [...a11yFailedStoryIds] },\n \"a11y violations found (non-blocking)\",\n );\n await emitAttemptLog(\n ctx.recordLog,\n ctx.logger,\n \"warn\",\n \"a11y violations found (non-blocking)\",\n {\n a11yStoryIds: [...a11yFailedStoryIds],\n },\n );\n }\n await builds.setStatus(ctx.build.id, status);\n\n if (ctx.build.isDefault && hasCaptures) {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n await baselines.removeOrphans(ctx.project.id, new Set(storyIds));\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import __tsdown_shims_path from 'node:path';
|
|
2
|
+
import __tsdown_shims_url from 'node:url';
|
|
3
|
+
import { DispatchDeps } from "./dispatch.mjs";
|
|
4
|
+
//#region src/capture/recovery.d.ts
|
|
5
|
+
/** Error recorded on attempts that a restart cut short; also the requeue-once marker. */
|
|
6
|
+
declare const INTERRUPTED_ERROR = "Interrupted by server restart";
|
|
7
|
+
/** Outcome of one recovery sweep. */
|
|
8
|
+
interface RecoveryResult {
|
|
9
|
+
requeued: string[];
|
|
10
|
+
failed: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Inputs for {@link recoverStuckCaptures}. */
|
|
13
|
+
interface RecoveryDeps {
|
|
14
|
+
deps: DispatchDeps;
|
|
15
|
+
enqueue: (buildId: string) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** Requeue (once) or fail every build still `capturing`; call once at boot, before serving jobs. */
|
|
18
|
+
declare function recoverStuckCaptures(recovery: RecoveryDeps): Promise<RecoveryResult>;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { INTERRUPTED_ERROR, RecoveryDeps, RecoveryResult, recoverStuckCaptures };
|
|
21
|
+
//# sourceMappingURL=recovery.d.mts.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { BuildModel } from "../models/build.mjs";
|
|
5
|
+
import { CaptureAttemptModel } from "../models/capture-attempt.mjs";
|
|
6
|
+
import { ProjectModel } from "../models/project.mjs";
|
|
7
|
+
import { postStatusesForBuild } from "./status-fanout.mjs";
|
|
8
|
+
import { eq, getTableColumns } from "drizzle-orm";
|
|
9
|
+
//#region src/capture/recovery.ts
|
|
10
|
+
/**
|
|
11
|
+
* Boot-time recovery for builds left in `capturing` by a server restart.
|
|
12
|
+
*
|
|
13
|
+
* Policy: requeue once. The interrupted attempt is closed with
|
|
14
|
+
* {@link INTERRUPTED_ERROR}; a build with no earlier interrupted attempt goes back to `pending`
|
|
15
|
+
* and is re-enqueued, otherwise it is marked `failed` for manual retry. A build is failed instead
|
|
16
|
+
* when the run before the interrupted one was itself interrupted, so a crash loop stops after
|
|
17
|
+
* one requeue while manual retries stay unrestricted. In-process queues only: remote queues redeliver on their own.
|
|
18
|
+
*/
|
|
19
|
+
/** Error recorded on attempts that a restart cut short; also the requeue-once marker. */
|
|
20
|
+
const INTERRUPTED_ERROR = "Interrupted by server restart";
|
|
21
|
+
/** Requeue (once) or fail every build still `capturing`; call once at boot, before serving jobs. */
|
|
22
|
+
async function recoverStuckCaptures(recovery) {
|
|
23
|
+
const { deps } = recovery;
|
|
24
|
+
const stuck = await deps.db.list(deps.tables.builds, { where: eq(getTableColumns(deps.tables.builds)["status"], "capturing") });
|
|
25
|
+
const outcomes = await Promise.all(stuck.map(async (build) => await recoverBuild(recovery, build)));
|
|
26
|
+
const result = {
|
|
27
|
+
requeued: stuck.filter((_, index) => outcomes[index]).map((build) => build.id),
|
|
28
|
+
failed: stuck.filter((_, index) => !outcomes[index]).map((build) => build.id)
|
|
29
|
+
};
|
|
30
|
+
if (stuck.length > 0) deps.logger.warn({
|
|
31
|
+
requeued: result.requeued,
|
|
32
|
+
failed: result.failed
|
|
33
|
+
}, "recovered builds interrupted by restart");
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/** Recover one build; returns true when it was requeued. */
|
|
37
|
+
async function recoverBuild(recovery, build) {
|
|
38
|
+
const { deps } = recovery;
|
|
39
|
+
const attempts = new CaptureAttemptModel(deps.db, deps.tables);
|
|
40
|
+
const history = await attempts.listByBuild(build.id);
|
|
41
|
+
const open = history.filter((a) => a.status === "queued" || a.status === "running");
|
|
42
|
+
const lastClosed = history.findLast((a) => !open.includes(a));
|
|
43
|
+
await Promise.all(open.map(async (a) => await attempts.markFinished(a.id, "failed", INTERRUPTED_ERROR)));
|
|
44
|
+
return lastClosed?.error === "Interrupted by server restart" ? await failInterrupted(deps, build) : await requeueInterrupted(recovery, build);
|
|
45
|
+
}
|
|
46
|
+
async function failInterrupted(deps, build) {
|
|
47
|
+
await new BuildModel(deps.db, deps.tables).setStatus(build.id, "failed");
|
|
48
|
+
await postFailure(deps, build);
|
|
49
|
+
deps.logger.error({ buildId: build.id }, "build interrupted by restart again; marked failed for manual retry");
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
async function requeueInterrupted(recovery, build) {
|
|
53
|
+
const { deps } = recovery;
|
|
54
|
+
await new BuildModel(deps.db, deps.tables).setStatus(build.id, "pending");
|
|
55
|
+
await recovery.enqueue(build.id);
|
|
56
|
+
deps.logger.warn({ buildId: build.id }, "build interrupted by restart; requeued once");
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/** Replace the lingering pending git status with a failure. */
|
|
60
|
+
async function postFailure(deps, build) {
|
|
61
|
+
const project = await new ProjectModel(deps.db, deps.tables).get(build.projectId);
|
|
62
|
+
if (!project) return;
|
|
63
|
+
await postStatusesForBuild({
|
|
64
|
+
db: deps.db,
|
|
65
|
+
tables: deps.tables,
|
|
66
|
+
project,
|
|
67
|
+
sha: build.gitSha,
|
|
68
|
+
status: "failure",
|
|
69
|
+
url: `/projects/${project.slug}/builds/${build.id}`,
|
|
70
|
+
providers: deps.gitHosts,
|
|
71
|
+
secret: deps.secret,
|
|
72
|
+
logger: deps.logger
|
|
73
|
+
}).catch((error) => {
|
|
74
|
+
deps.logger.error({
|
|
75
|
+
err: error,
|
|
76
|
+
buildId: build.id
|
|
77
|
+
}, "failed to post failure status");
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
export { INTERRUPTED_ERROR, recoverStuckCaptures };
|
|
82
|
+
|
|
83
|
+
//# sourceMappingURL=recovery.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recovery.mjs","names":[],"sources":["../../src/capture/recovery.ts"],"sourcesContent":["/**\n * Boot-time recovery for builds left in `capturing` by a server restart.\n *\n * Policy: requeue once. The interrupted attempt is closed with\n * {@link INTERRUPTED_ERROR}; a build with no earlier interrupted attempt goes back to `pending`\n * and is re-enqueued, otherwise it is marked `failed` for manual retry. A build is failed instead\n * when the run before the interrupted one was itself interrupted, so a crash loop stops after\n * one requeue while manual retries stay unrestricted. In-process queues only: remote queues redeliver on their own.\n */\nimport { eq, getTableColumns } from \"drizzle-orm\";\nimport type { SQLWrapper } from \"drizzle-orm\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { CaptureAttemptModel } from \"../models/capture-attempt.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { DispatchDeps } from \"./dispatch.ts\";\nimport { postStatusesForBuild } from \"./status-fanout.ts\";\n\n/** Error recorded on attempts that a restart cut short; also the requeue-once marker. */\nexport const INTERRUPTED_ERROR = \"Interrupted by server restart\";\n\n/** Outcome of one recovery sweep. */\nexport interface RecoveryResult {\n requeued: string[];\n failed: string[];\n}\n\n/** Inputs for {@link recoverStuckCaptures}. */\nexport interface RecoveryDeps {\n deps: DispatchDeps;\n enqueue: (buildId: string) => Promise<void>;\n}\n\n/** Requeue (once) or fail every build still `capturing`; call once at boot, before serving jobs. */\nexport async function recoverStuckCaptures(recovery: RecoveryDeps): Promise<RecoveryResult> {\n const { deps } = recovery;\n const stuck = (await deps.db.list(deps.tables.builds, {\n where: eq(getTableColumns(deps.tables.builds)[\"status\"] as unknown as SQLWrapper, \"capturing\"),\n })) as unknown as Build[];\n const outcomes = await Promise.all(\n stuck.map(async (build) => await recoverBuild(recovery, build)),\n );\n const result: RecoveryResult = {\n requeued: stuck.filter((_, index) => outcomes[index]).map((build) => build.id),\n failed: stuck.filter((_, index) => !outcomes[index]).map((build) => build.id),\n };\n if (stuck.length > 0) {\n deps.logger.warn(\n { requeued: result.requeued, failed: result.failed },\n \"recovered builds interrupted by restart\",\n );\n }\n return result;\n}\n\n/** Recover one build; returns true when it was requeued. */\nasync function recoverBuild(recovery: RecoveryDeps, build: Build): Promise<boolean> {\n const { deps } = recovery;\n const attempts = new CaptureAttemptModel(deps.db, deps.tables);\n const history = await attempts.listByBuild(build.id);\n const open = history.filter((a) => a.status === \"queued\" || a.status === \"running\");\n // Requeue-once: the run before the interrupted one was itself interrupted => crash loop.\n const lastClosed = history.findLast((a) => !open.includes(a));\n await Promise.all(\n open.map(async (a) => await attempts.markFinished(a.id, \"failed\", INTERRUPTED_ERROR)),\n );\n return lastClosed?.error === INTERRUPTED_ERROR\n ? await failInterrupted(deps, build)\n : await requeueInterrupted(recovery, build);\n}\n\nasync function failInterrupted(deps: DispatchDeps, build: Build): Promise<false> {\n await new BuildModel(deps.db, deps.tables).setStatus(build.id, \"failed\");\n await postFailure(deps, build);\n deps.logger.error(\n { buildId: build.id },\n \"build interrupted by restart again; marked failed for manual retry\",\n );\n return false;\n}\n\nasync function requeueInterrupted(recovery: RecoveryDeps, build: Build): Promise<true> {\n const { deps } = recovery;\n await new BuildModel(deps.db, deps.tables).setStatus(build.id, \"pending\");\n await recovery.enqueue(build.id);\n deps.logger.warn({ buildId: build.id }, \"build interrupted by restart; requeued once\");\n return true;\n}\n\n/** Replace the lingering pending git status with a failure. */\nasync function postFailure(deps: DispatchDeps, build: Build): Promise<void> {\n const project = await new ProjectModel(deps.db, deps.tables).get(build.projectId);\n if (!project) {\n return;\n }\n await postStatusesForBuild({\n db: deps.db,\n tables: deps.tables,\n project,\n sha: build.gitSha,\n status: \"failure\",\n url: `/projects/${project.slug}/builds/${build.id}`,\n providers: deps.gitHosts,\n secret: deps.secret,\n logger: deps.logger,\n }).catch((error: unknown) => {\n deps.logger.error({ err: error, buildId: build.id }, \"failed to post failure status\");\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAmBA,MAAa,oBAAoB;;AAejC,eAAsB,qBAAqB,UAAiD;CAC1F,MAAM,EAAE,SAAS;CACjB,MAAM,QAAS,MAAM,KAAK,GAAG,KAAK,KAAK,OAAO,QAAQ,EACpD,OAAO,GAAG,gBAAgB,KAAK,OAAO,MAAM,CAAC,CAAC,WAAoC,WAAW,EAC/F,CAAC;CACD,MAAM,WAAW,MAAM,QAAQ,IAC7B,MAAM,IAAI,OAAO,UAAU,MAAM,aAAa,UAAU,KAAK,CAAC,CAChE;CACA,MAAM,SAAyB;EAC7B,UAAU,MAAM,QAAQ,GAAG,UAAU,SAAS,MAAM,CAAC,CAAC,KAAK,UAAU,MAAM,EAAE;EAC7E,QAAQ,MAAM,QAAQ,GAAG,UAAU,CAAC,SAAS,MAAM,CAAC,CAAC,KAAK,UAAU,MAAM,EAAE;CAC9E;CACA,IAAI,MAAM,SAAS,GACjB,KAAK,OAAO,KACV;EAAE,UAAU,OAAO;EAAU,QAAQ,OAAO;CAAO,GACnD,yCACF;CAEF,OAAO;AACT;;AAGA,eAAe,aAAa,UAAwB,OAAgC;CAClF,MAAM,EAAE,SAAS;CACjB,MAAM,WAAW,IAAI,oBAAoB,KAAK,IAAI,KAAK,MAAM;CAC7D,MAAM,UAAU,MAAM,SAAS,YAAY,MAAM,EAAE;CACnD,MAAM,OAAO,QAAQ,QAAQ,MAAM,EAAE,WAAW,YAAY,EAAE,WAAW,SAAS;CAElF,MAAM,aAAa,QAAQ,UAAU,MAAM,CAAC,KAAK,SAAS,CAAC,CAAC;CAC5D,MAAM,QAAQ,IACZ,KAAK,IAAI,OAAO,MAAM,MAAM,SAAS,aAAa,EAAE,IAAI,UAAU,iBAAiB,CAAC,CACtF;CACA,OAAO,YAAY,UAAA,kCACf,MAAM,gBAAgB,MAAM,KAAK,IACjC,MAAM,mBAAmB,UAAU,KAAK;AAC9C;AAEA,eAAe,gBAAgB,MAAoB,OAA8B;CAC/E,MAAM,IAAI,WAAW,KAAK,IAAI,KAAK,MAAM,CAAC,CAAC,UAAU,MAAM,IAAI,QAAQ;CACvE,MAAM,YAAY,MAAM,KAAK;CAC7B,KAAK,OAAO,MACV,EAAE,SAAS,MAAM,GAAG,GACpB,oEACF;CACA,OAAO;AACT;AAEA,eAAe,mBAAmB,UAAwB,OAA6B;CACrF,MAAM,EAAE,SAAS;CACjB,MAAM,IAAI,WAAW,KAAK,IAAI,KAAK,MAAM,CAAC,CAAC,UAAU,MAAM,IAAI,SAAS;CACxE,MAAM,SAAS,QAAQ,MAAM,EAAE;CAC/B,KAAK,OAAO,KAAK,EAAE,SAAS,MAAM,GAAG,GAAG,6CAA6C;CACrF,OAAO;AACT;;AAGA,eAAe,YAAY,MAAoB,OAA6B;CAC1E,MAAM,UAAU,MAAM,IAAI,aAAa,KAAK,IAAI,KAAK,MAAM,CAAC,CAAC,IAAI,MAAM,SAAS;CAChF,IAAI,CAAC,SACH;CAEF,MAAM,qBAAqB;EACzB,IAAI,KAAK;EACT,QAAQ,KAAK;EACb;EACA,KAAK,MAAM;EACX,QAAQ;EACR,KAAK,aAAa,QAAQ,KAAK,UAAU,MAAM;EAC/C,WAAW,KAAK;EAChB,QAAQ,KAAK;EACb,QAAQ,KAAK;CACf,CAAC,CAAC,CAAC,OAAO,UAAmB;EAC3B,KAAK,OAAO,MAAM;GAAE,KAAK;GAAO,SAAS,MAAM;EAAG,GAAG,+BAA+B;CACtF,CAAC;AACH"}
|