@storyshelf/app 0.9.0-next-48e06c3 → 0.9.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/dist/capture-alerts.mjs
CHANGED
|
@@ -20,7 +20,29 @@ function alreadyAlerted(build) {
|
|
|
20
20
|
async function notifyCaptureFailed(options, config, logger, buildId, publicOrigin) {
|
|
21
21
|
const build = await new BuildModel(options.database).get(buildId);
|
|
22
22
|
if (build?.status !== "failed" || alreadyAlerted(build)) return;
|
|
23
|
-
|
|
23
|
+
await sendCaptureAlert(options, config, logger, build, publicOrigin);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Alert admins that a capture ended without its build being failed: the queue's exhausted-job hook
|
|
27
|
+
* kept failing, so the build may still read `capturing`. Only the system alert goes out, since the
|
|
28
|
+
* build did not fail by a verdict; a build already settled by someone else needs none.
|
|
29
|
+
*/
|
|
30
|
+
async function notifyCaptureStuck(options, config, logger, buildId, publicOrigin) {
|
|
31
|
+
const build = await new BuildModel(options.database).get(buildId);
|
|
32
|
+
if (build === null || ![
|
|
33
|
+
"pending",
|
|
34
|
+
"capturing",
|
|
35
|
+
"failed"
|
|
36
|
+
].includes(build.status)) return;
|
|
37
|
+
if (alreadyAlerted(build)) return;
|
|
38
|
+
await notifySystemWith(alertDeps(options, config, logger, publicOrigin), "sys:capture-failed", {
|
|
39
|
+
buildId,
|
|
40
|
+
projectId: build.projectId,
|
|
41
|
+
gitBranch: build.gitBranch
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function alertDeps(options, config, logger, publicOrigin) {
|
|
45
|
+
return {
|
|
24
46
|
db: options.database,
|
|
25
47
|
config,
|
|
26
48
|
ui: options.ui ?? {},
|
|
@@ -29,6 +51,10 @@ async function notifyCaptureFailed(options, config, logger, buildId, publicOrigi
|
|
|
29
51
|
notificationsConfig: options.notifications?.config,
|
|
30
52
|
publicOrigin: publicOrigin ? { url: publicOrigin } : void 0
|
|
31
53
|
};
|
|
54
|
+
}
|
|
55
|
+
async function sendCaptureAlert(options, config, logger, build, publicOrigin) {
|
|
56
|
+
const buildId = build.id;
|
|
57
|
+
const deps = alertDeps(options, config, logger, publicOrigin);
|
|
32
58
|
await notifySystemWith(deps, "sys:capture-failed", {
|
|
33
59
|
buildId,
|
|
34
60
|
projectId: build.projectId,
|
|
@@ -40,6 +66,6 @@ async function notifyCaptureFailed(options, config, logger, buildId, publicOrigi
|
|
|
40
66
|
status: "failed"
|
|
41
67
|
}, `/builds/${buildId}`);
|
|
42
68
|
}
|
|
43
|
-
export { alreadyAlerted, notifyCaptureFailed };
|
|
69
|
+
export { alreadyAlerted, notifyCaptureFailed, notifyCaptureStuck };
|
|
44
70
|
|
|
45
71
|
//# sourceMappingURL=capture-alerts.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capture-alerts.mjs","names":[],"sources":["../src/capture-alerts.ts"],"sourcesContent":["/** Admin alerts for captures that leave their build failed; the app, not the capture child, sends them. */\nimport type { ShelfConfig, ShelfOptions } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { BuildModel, ProjectModel } from \"@storyshelf/core/models\";\nimport { notifyProjectWith, notifySystemWith } from \"./notify.ts\";\n\n/**\n * Builds already alerted about, keyed by the build's last update. A sharded build settles one job\n * per shard and every one of them sees the failed build; only the first alerts. A retry rewrites\n * the build, so its next failure is a new key.\n */\nconst alerted = new Set<string>();\n\nexport function alreadyAlerted(build: { id: string; updatedAt: string }): boolean {\n const key = `${build.id}@${build.updatedAt}`;\n if (alerted.has(key)) {\n return true;\n }\n alerted.add(key);\n return false;\n}\n\n/** Alert admins when a capture leaves its build failed. */\nexport async function notifyCaptureFailed(\n options: ShelfOptions,\n config: ShelfConfig,\n logger: Logger,\n buildId: string,\n publicOrigin?: () => Promise<string | undefined>,\n): Promise<void> {\n const build = await new BuildModel(options.database).get(buildId);\n if (build?.status !== \"failed\" || alreadyAlerted(build)) {\n return;\n }\n const
|
|
1
|
+
{"version":3,"file":"capture-alerts.mjs","names":[],"sources":["../src/capture-alerts.ts"],"sourcesContent":["/** Admin alerts for captures that leave their build failed; the app, not the capture child, sends them. */\nimport type { ShelfConfig, ShelfOptions } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { BuildModel, ProjectModel } from \"@storyshelf/core/models\";\nimport { notifyProjectWith, notifySystemWith } from \"./notify.ts\";\n\n/**\n * Builds already alerted about, keyed by the build's last update. A sharded build settles one job\n * per shard and every one of them sees the failed build; only the first alerts. A retry rewrites\n * the build, so its next failure is a new key.\n */\nconst alerted = new Set<string>();\n\nexport function alreadyAlerted(build: { id: string; updatedAt: string }): boolean {\n const key = `${build.id}@${build.updatedAt}`;\n if (alerted.has(key)) {\n return true;\n }\n alerted.add(key);\n return false;\n}\n\n/** Alert admins when a capture leaves its build failed. */\nexport async function notifyCaptureFailed(\n options: ShelfOptions,\n config: ShelfConfig,\n logger: Logger,\n buildId: string,\n publicOrigin?: () => Promise<string | undefined>,\n): Promise<void> {\n const build = await new BuildModel(options.database).get(buildId);\n if (build?.status !== \"failed\" || alreadyAlerted(build)) {\n return;\n }\n await sendCaptureAlert(options, config, logger, build, publicOrigin);\n}\n\n/**\n * Alert admins that a capture ended without its build being failed: the queue's exhausted-job hook\n * kept failing, so the build may still read `capturing`. Only the system alert goes out, since the\n * build did not fail by a verdict; a build already settled by someone else needs none.\n */\nexport async function notifyCaptureStuck(\n options: ShelfOptions,\n config: ShelfConfig,\n logger: Logger,\n buildId: string,\n publicOrigin?: () => Promise<string | undefined>,\n): Promise<void> {\n const build = await new BuildModel(options.database).get(buildId);\n if (build === null || ![\"pending\", \"capturing\", \"failed\"].includes(build.status)) {\n return;\n }\n if (alreadyAlerted(build)) {\n return;\n }\n await notifySystemWith(alertDeps(options, config, logger, publicOrigin), \"sys:capture-failed\", {\n buildId,\n projectId: build.projectId,\n gitBranch: build.gitBranch,\n });\n}\n\nfunction alertDeps(\n options: ShelfOptions,\n config: ShelfConfig,\n logger: Logger,\n publicOrigin?: () => Promise<string | undefined>,\n): Parameters<typeof notifySystemWith>[0] {\n return {\n db: options.database,\n config,\n ui: options.ui ?? {},\n logger,\n notifiers: options.notifications?.notifiers ?? [],\n notificationsConfig: options.notifications?.config,\n publicOrigin: publicOrigin ? { url: publicOrigin } : undefined,\n };\n}\n\nasync function sendCaptureAlert(\n options: ShelfOptions,\n config: ShelfConfig,\n logger: Logger,\n build: { id: string; projectId: string; gitBranch: string },\n publicOrigin?: () => Promise<string | undefined>,\n): Promise<void> {\n const buildId = build.id;\n const deps = alertDeps(options, config, logger, publicOrigin);\n await notifySystemWith(deps, \"sys:capture-failed\", {\n buildId,\n projectId: build.projectId,\n gitBranch: build.gitBranch,\n });\n // Subscribers of the project's `build:failed` topic hear of a failed capture too.\n const project = await new ProjectModel(options.database).get(build.projectId);\n if (project) {\n await notifyProjectWith(\n deps,\n project,\n \"build:failed\",\n { buildId, status: \"failed\" },\n `/builds/${buildId}`,\n );\n }\n}\n"],"mappings":";;;;;;;;;;;AAWA,MAAM,0BAAU,IAAI,IAAY;AAEhC,SAAgB,eAAe,OAAmD;CAChF,MAAM,MAAM,GAAG,MAAM,GAAG,GAAG,MAAM;CACjC,IAAI,QAAQ,IAAI,GAAG,GACjB,OAAO;CAET,QAAQ,IAAI,GAAG;CACf,OAAO;AACT;;AAGA,eAAsB,oBACpB,SACA,QACA,QACA,SACA,cACe;CACf,MAAM,QAAQ,MAAM,IAAI,WAAW,QAAQ,QAAQ,CAAC,CAAC,IAAI,OAAO;CAChE,IAAI,OAAO,WAAW,YAAY,eAAe,KAAK,GACpD;CAEF,MAAM,iBAAiB,SAAS,QAAQ,QAAQ,OAAO,YAAY;AACrE;;;;;;AAOA,eAAsB,mBACpB,SACA,QACA,QACA,SACA,cACe;CACf,MAAM,QAAQ,MAAM,IAAI,WAAW,QAAQ,QAAQ,CAAC,CAAC,IAAI,OAAO;CAChE,IAAI,UAAU,QAAQ,CAAC;EAAC;EAAW;EAAa;CAAQ,CAAC,CAAC,SAAS,MAAM,MAAM,GAC7E;CAEF,IAAI,eAAe,KAAK,GACtB;CAEF,MAAM,iBAAiB,UAAU,SAAS,QAAQ,QAAQ,YAAY,GAAG,sBAAsB;EAC7F;EACA,WAAW,MAAM;EACjB,WAAW,MAAM;CACnB,CAAC;AACH;AAEA,SAAS,UACP,SACA,QACA,QACA,cACwC;CACxC,OAAO;EACL,IAAI,QAAQ;EACZ;EACA,IAAI,QAAQ,MAAM,CAAC;EACnB;EACA,WAAW,QAAQ,eAAe,aAAa,CAAC;EAChD,qBAAqB,QAAQ,eAAe;EAC5C,cAAc,eAAe,EAAE,KAAK,aAAa,IAAI,KAAA;CACvD;AACF;AAEA,eAAe,iBACb,SACA,QACA,QACA,OACA,cACe;CACf,MAAM,UAAU,MAAM;CACtB,MAAM,OAAO,UAAU,SAAS,QAAQ,QAAQ,YAAY;CAC5D,MAAM,iBAAiB,MAAM,sBAAsB;EACjD;EACA,WAAW,MAAM;EACjB,WAAW,MAAM;CACnB,CAAC;CAED,MAAM,UAAU,MAAM,IAAI,aAAa,QAAQ,QAAQ,CAAC,CAAC,IAAI,MAAM,SAAS;CAC5E,IAAI,SACF,MAAM,kBACJ,MACA,SACA,gBACA;EAAE;EAAS,QAAQ;CAAS,GAC5B,WAAW,SACb;AAEJ"}
|
package/dist/capture-setup.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { credentialKeys } from "./credential-keys.mjs";
|
|
5
|
-
import { notifyCaptureFailed } from "./capture-alerts.mjs";
|
|
5
|
+
import { notifyCaptureFailed, notifyCaptureStuck } from "./capture-alerts.mjs";
|
|
6
6
|
import { createForkedHandler, shouldForkCapture } from "./capture-process.mjs";
|
|
7
7
|
import { DEFAULT_SCRATCH_DIR } from "@storyshelf/core/config";
|
|
8
8
|
import { currentTraceparent } from "@storyshelf/observability";
|
|
@@ -45,6 +45,9 @@ function setupCapture(options, config, delivery, logger, jobs) {
|
|
|
45
45
|
await notifyCaptureFailed(options, config, logger, job.buildId, delivery.publicOrigin);
|
|
46
46
|
};
|
|
47
47
|
jobs.exhausted.capture = failed;
|
|
48
|
+
jobs.abandoned.capture = async (job) => {
|
|
49
|
+
await notifyCaptureStuck(options, config, logger, job.buildId, delivery.publicOrigin);
|
|
50
|
+
};
|
|
48
51
|
const inProcess = createCaptureHandler(handlerOptions);
|
|
49
52
|
return {
|
|
50
53
|
queue,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capture-setup.mjs","names":[],"sources":["../src/capture-setup.ts"],"sourcesContent":["/**\n * Capture wiring (ADR 0006, decisions 4 to 6): where uploads queue their capture, and the handler\n * of `capture` jobs. Captures queue on the shared database queue. The handler runs them in this process, or in a forked child the parent pushes jobs\n * to (`testing.workerEntry`). The consumer that polls the queue and calls the handler is built\n * in `jobs-setup.ts`.\n */\nimport type { CaptureQueue } from \"@storyshelf/core/adapter/capture-queue\";\nimport type { CaptureRunner } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport {\n captureDispatchDeps,\n createCaptureHandler,\n DEFAULT_RETRIES,\n failCrashedCapture,\n isFinalAttempt,\n type BuildStatePublisher,\n type CaptureHandlerOptions,\n type ConsumerJob,\n type KindHandler,\n} from \"@storyshelf/core/capture\";\nimport { DEFAULT_SCRATCH_DIR } from \"@storyshelf/core/config\";\nimport type { ShelfConfig, ShelfOptions } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { currentTraceparent } from \"@storyshelf/observability\";\nimport { notifyCaptureFailed } from \"./capture-alerts.ts\";\nimport { createForkedHandler, shouldForkCapture } from \"./capture-process.ts\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport type { JobQueue } from \"./job-queue.ts\";\n\n/** The `capture` handler, with a way to stop a forked child once the consumer has drained. */\nexport interface CaptureHandler extends KindHandler {\n close?: () => Promise<void>;\n}\n\n/** What uploads and the consumer need of capture. */\nexport interface CaptureWiring {\n /** Where captures queue: the database queue, or null without `testing`. */\n queue: CaptureQueue | null;\n /** Queues a capture for a build; undefined without `testing`. */\n enqueueCapture: ((buildId: string, reqId?: string) => Promise<void>) | undefined;\n /** Runs `capture` jobs here; null without `testing`. */\n handler: CaptureHandler | null;\n}\n\n/** What a capture hands its results to. */\ninterface Delivery {\n notifiers: NotifierProvider[];\n buildState: BuildStatePublisher;\n /** The resolved public origin (ADR 0007): status links and targets that need a URL use it. */\n publicOrigin: () => Promise<string | undefined>;\n}\n\n/** Queue a build, continuing the active request trace when present. */\nfunction enqueuerFor(queue: CaptureQueue): (buildId: string, reqId?: string) => Promise<void> {\n return async (buildId, reqId) => {\n const traceparent = currentTraceparent();\n await queue.enqueue({ buildId, reqId, ...(traceparent ? { traceparent } : {}) });\n };\n}\n\n/**\n * Wire capture for `options`: `testing` always has a runner, so this instance consumes the\n * captures it queues. Shard jobs go back onto the same queue the capture came from.\n */\nexport function setupCapture(\n options: ShelfOptions,\n config: ShelfConfig,\n delivery: Delivery,\n logger: Logger,\n jobs: JobQueue,\n): CaptureWiring {\n const testing = options.testing;\n if (testing === undefined) {\n return { queue: null, enqueueCapture: undefined, handler: null };\n }\n const queue = jobs.view;\n const enqueueCapture = enqueuerFor(queue);\n const handlerOptions = captureHandlerOptions(options, config, delivery, logger, {\n runner: testing.runner,\n enqueue: async (job) => {\n await queue.enqueue(job);\n },\n });\n const retries = options.testing?.retries ?? DEFAULT_RETRIES;\n const dispatch = captureDispatchDeps(handlerOptions, retries);\n const failed = async (job: ConsumerJob, error: string): Promise<void> => {\n await failCrashedCapture(dispatch, job, error);\n await notifyCaptureFailed(options, config, logger, job.buildId, delivery.publicOrigin);\n };\n jobs.exhausted.capture = failed;\n const inProcess = createCaptureHandler(handlerOptions);\n const handler = shouldForkCapture(testing, logger)\n ? forkedHandler(options, { fallback: inProcess, retries, onCrashed: failed, logger })\n : inProcess;\n return {\n queue,\n enqueueCapture,\n handler: alerting(handler, retries, async (buildId) => {\n await notifyCaptureFailed(options, config, logger, buildId, delivery.publicOrigin);\n }),\n };\n}\n\nfunction captureHandlerOptions(\n options: ShelfOptions,\n config: ShelfConfig,\n delivery: Delivery,\n logger: Logger,\n parts: { runner: CaptureRunner; enqueue: CaptureHandlerOptions[\"enqueue\"] },\n): CaptureHandlerOptions {\n return {\n db: options.database,\n storage: options.storage,\n runner: parts.runner,\n // `resolveShelfOptions` defaults scratchDir; the fallback covers hand-built configs.\n scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,\n viewports: config.viewports,\n logger,\n secret: credentialKeys(config),\n design: options.design,\n shardSize: options.testing?.shardSize,\n notifiers: delivery.notifiers,\n buildState: delivery.buildState,\n publicOrigin: delivery.publicOrigin,\n config,\n enqueue: parts.enqueue,\n maxRetries: options.testing?.retries,\n };\n}\n\nfunction forkedHandler(\n options: ShelfOptions,\n parts: {\n fallback: KindHandler;\n retries: number;\n onCrashed: (job: ConsumerJob, error: string) => Promise<void>;\n logger: Logger;\n },\n): CaptureHandler {\n const testing = options.testing;\n return createForkedHandler({\n workerEntry: testing?.workerEntry ?? \"\",\n shardSize: testing?.shardSize,\n imageEngine: testing?.images?.engine,\n diffThreads: testing?.compare?.threads,\n ...parts,\n });\n}\n\n/**\n * Alert admins when a capture left its build failed: after a run that ended, and after a final\n * attempt that threw (the build may then already be failed by a crash hook). Alerts stay in this\n * process whichever process rendered.\n */\nfunction alerting(\n inner: CaptureHandler,\n retries: number,\n alert: (buildId: string) => Promise<void>,\n): CaptureHandler {\n return {\n accepts: (job) => inner.accepts?.(job) !== false,\n run: async (job, context) => {\n try {\n await inner.run(job, context);\n } catch (error) {\n if (isFinalAttempt(job, retries)) {\n await alert(job.buildId);\n }\n throw error;\n }\n await alert(job.buildId);\n },\n cancel: async (job) => {\n await inner.cancel?.(job);\n },\n ...(inner.close ? { close: inner.close } : {}),\n };\n}\n"],"mappings":";;;;;;;;;;;AAqDA,SAAS,YAAY,OAAyE;CAC5F,OAAO,OAAO,SAAS,UAAU;EAC/B,MAAM,cAAc,mBAAmB;EACvC,MAAM,MAAM,QAAQ;GAAE;GAAS;GAAO,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;EAAG,CAAC;CACjF;AACF;;;;;AAMA,SAAgB,aACd,SACA,QACA,UACA,QACA,MACe;CACf,MAAM,UAAU,QAAQ;CACxB,IAAI,YAAY,KAAA,GACd,OAAO;EAAE,OAAO;EAAM,gBAAgB,KAAA;EAAW,SAAS;CAAK;CAEjE,MAAM,QAAQ,KAAK;CACnB,MAAM,iBAAiB,YAAY,KAAK;CACxC,MAAM,iBAAiB,sBAAsB,SAAS,QAAQ,UAAU,QAAQ;EAC9E,QAAQ,QAAQ;EAChB,SAAS,OAAO,QAAQ;GACtB,MAAM,MAAM,QAAQ,GAAG;EACzB;CACF,CAAC;CACD,MAAM,UAAU,QAAQ,SAAS,WAAW;CAC5C,MAAM,WAAW,oBAAoB,gBAAgB,OAAO;CAC5D,MAAM,SAAS,OAAO,KAAkB,UAAiC;EACvE,MAAM,mBAAmB,UAAU,KAAK,KAAK;EAC7C,MAAM,oBAAoB,SAAS,QAAQ,QAAQ,IAAI,SAAS,SAAS,YAAY;CACvF;CACA,KAAK,UAAU,UAAU;CACzB,MAAM,YAAY,qBAAqB,cAAc;CAIrD,OAAO;EACL;EACA;EACA,SAAS,SANK,kBAAkB,SAAS,MAAM,IAC7C,cAAc,SAAS;GAAE,UAAU;GAAW;GAAS,WAAW;GAAQ;EAAO,CAAC,IAClF,WAIyB,SAAS,OAAO,YAAY;GACrD,MAAM,oBAAoB,SAAS,QAAQ,QAAQ,SAAS,SAAS,YAAY;EACnF,CAAC;CACH;AACF;AAEA,SAAS,sBACP,SACA,QACA,UACA,QACA,OACuB;CACvB,OAAO;EACL,IAAI,QAAQ;EACZ,SAAS,QAAQ;EACjB,QAAQ,MAAM;EAEd,YAAY,OAAO,cAAc;EACjC,WAAW,OAAO;EAClB;EACA,QAAQ,eAAe,MAAM;EAC7B,QAAQ,QAAQ;EAChB,WAAW,QAAQ,SAAS;EAC5B,WAAW,SAAS;EACpB,YAAY,SAAS;EACrB,cAAc,SAAS;EACvB;EACA,SAAS,MAAM;EACf,YAAY,QAAQ,SAAS;CAC/B;AACF;AAEA,SAAS,cACP,SACA,OAMgB;CAChB,MAAM,UAAU,QAAQ;CACxB,OAAO,oBAAoB;EACzB,aAAa,SAAS,eAAe;EACrC,WAAW,SAAS;EACpB,aAAa,SAAS,QAAQ;EAC9B,aAAa,SAAS,SAAS;EAC/B,GAAG;CACL,CAAC;AACH;;;;;;AAOA,SAAS,SACP,OACA,SACA,OACgB;CAChB,OAAO;EACL,UAAU,QAAQ,MAAM,UAAU,GAAG,MAAM;EAC3C,KAAK,OAAO,KAAK,YAAY;GAC3B,IAAI;IACF,MAAM,MAAM,IAAI,KAAK,OAAO;GAC9B,SAAS,OAAO;IACd,IAAI,eAAe,KAAK,OAAO,GAC7B,MAAM,MAAM,IAAI,OAAO;IAEzB,MAAM;GACR;GACA,MAAM,MAAM,IAAI,OAAO;EACzB;EACA,QAAQ,OAAO,QAAQ;GACrB,MAAM,MAAM,SAAS,GAAG;EAC1B;EACA,GAAI,MAAM,QAAQ,EAAE,OAAO,MAAM,MAAM,IAAI,CAAC;CAC9C;AACF"}
|
|
1
|
+
{"version":3,"file":"capture-setup.mjs","names":[],"sources":["../src/capture-setup.ts"],"sourcesContent":["/**\n * Capture wiring (ADR 0006, decisions 4 to 6): where uploads queue their capture, and the handler\n * of `capture` jobs. Captures queue on the shared database queue. The handler runs them in this process, or in a forked child the parent pushes jobs\n * to (`testing.workerEntry`). The consumer that polls the queue and calls the handler is built\n * in `jobs-setup.ts`.\n */\nimport type { CaptureQueue } from \"@storyshelf/core/adapter/capture-queue\";\nimport type { CaptureRunner } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport {\n captureDispatchDeps,\n createCaptureHandler,\n DEFAULT_RETRIES,\n failCrashedCapture,\n isFinalAttempt,\n type BuildStatePublisher,\n type CaptureHandlerOptions,\n type ConsumerJob,\n type KindHandler,\n} from \"@storyshelf/core/capture\";\nimport { DEFAULT_SCRATCH_DIR } from \"@storyshelf/core/config\";\nimport type { ShelfConfig, ShelfOptions } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { currentTraceparent } from \"@storyshelf/observability\";\nimport { notifyCaptureFailed, notifyCaptureStuck } from \"./capture-alerts.ts\";\nimport { createForkedHandler, shouldForkCapture } from \"./capture-process.ts\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport type { JobQueue } from \"./job-queue.ts\";\n\n/** The `capture` handler, with a way to stop a forked child once the consumer has drained. */\nexport interface CaptureHandler extends KindHandler {\n close?: () => Promise<void>;\n}\n\n/** What uploads and the consumer need of capture. */\nexport interface CaptureWiring {\n /** Where captures queue: the database queue, or null without `testing`. */\n queue: CaptureQueue | null;\n /** Queues a capture for a build; undefined without `testing`. */\n enqueueCapture: ((buildId: string, reqId?: string) => Promise<void>) | undefined;\n /** Runs `capture` jobs here; null without `testing`. */\n handler: CaptureHandler | null;\n}\n\n/** What a capture hands its results to. */\ninterface Delivery {\n notifiers: NotifierProvider[];\n buildState: BuildStatePublisher;\n /** The resolved public origin (ADR 0007): status links and targets that need a URL use it. */\n publicOrigin: () => Promise<string | undefined>;\n}\n\n/** Queue a build, continuing the active request trace when present. */\nfunction enqueuerFor(queue: CaptureQueue): (buildId: string, reqId?: string) => Promise<void> {\n return async (buildId, reqId) => {\n const traceparent = currentTraceparent();\n await queue.enqueue({ buildId, reqId, ...(traceparent ? { traceparent } : {}) });\n };\n}\n\n/**\n * Wire capture for `options`: `testing` always has a runner, so this instance consumes the\n * captures it queues. Shard jobs go back onto the same queue the capture came from.\n */\nexport function setupCapture(\n options: ShelfOptions,\n config: ShelfConfig,\n delivery: Delivery,\n logger: Logger,\n jobs: JobQueue,\n): CaptureWiring {\n const testing = options.testing;\n if (testing === undefined) {\n return { queue: null, enqueueCapture: undefined, handler: null };\n }\n const queue = jobs.view;\n const enqueueCapture = enqueuerFor(queue);\n const handlerOptions = captureHandlerOptions(options, config, delivery, logger, {\n runner: testing.runner,\n enqueue: async (job) => {\n await queue.enqueue(job);\n },\n });\n const retries = options.testing?.retries ?? DEFAULT_RETRIES;\n const dispatch = captureDispatchDeps(handlerOptions, retries);\n const failed = async (job: ConsumerJob, error: string): Promise<void> => {\n await failCrashedCapture(dispatch, job, error);\n await notifyCaptureFailed(options, config, logger, job.buildId, delivery.publicOrigin);\n };\n jobs.exhausted.capture = failed;\n jobs.abandoned.capture = async (job): Promise<void> => {\n await notifyCaptureStuck(options, config, logger, job.buildId, delivery.publicOrigin);\n };\n const inProcess = createCaptureHandler(handlerOptions);\n const handler = shouldForkCapture(testing, logger)\n ? forkedHandler(options, { fallback: inProcess, retries, onCrashed: failed, logger })\n : inProcess;\n return {\n queue,\n enqueueCapture,\n handler: alerting(handler, retries, async (buildId) => {\n await notifyCaptureFailed(options, config, logger, buildId, delivery.publicOrigin);\n }),\n };\n}\n\nfunction captureHandlerOptions(\n options: ShelfOptions,\n config: ShelfConfig,\n delivery: Delivery,\n logger: Logger,\n parts: { runner: CaptureRunner; enqueue: CaptureHandlerOptions[\"enqueue\"] },\n): CaptureHandlerOptions {\n return {\n db: options.database,\n storage: options.storage,\n runner: parts.runner,\n // `resolveShelfOptions` defaults scratchDir; the fallback covers hand-built configs.\n scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,\n viewports: config.viewports,\n logger,\n secret: credentialKeys(config),\n design: options.design,\n shardSize: options.testing?.shardSize,\n notifiers: delivery.notifiers,\n buildState: delivery.buildState,\n publicOrigin: delivery.publicOrigin,\n config,\n enqueue: parts.enqueue,\n maxRetries: options.testing?.retries,\n };\n}\n\nfunction forkedHandler(\n options: ShelfOptions,\n parts: {\n fallback: KindHandler;\n retries: number;\n onCrashed: (job: ConsumerJob, error: string) => Promise<void>;\n logger: Logger;\n },\n): CaptureHandler {\n const testing = options.testing;\n return createForkedHandler({\n workerEntry: testing?.workerEntry ?? \"\",\n shardSize: testing?.shardSize,\n imageEngine: testing?.images?.engine,\n diffThreads: testing?.compare?.threads,\n ...parts,\n });\n}\n\n/**\n * Alert admins when a capture left its build failed: after a run that ended, and after a final\n * attempt that threw (the build may then already be failed by a crash hook). Alerts stay in this\n * process whichever process rendered.\n */\nfunction alerting(\n inner: CaptureHandler,\n retries: number,\n alert: (buildId: string) => Promise<void>,\n): CaptureHandler {\n return {\n accepts: (job) => inner.accepts?.(job) !== false,\n run: async (job, context) => {\n try {\n await inner.run(job, context);\n } catch (error) {\n if (isFinalAttempt(job, retries)) {\n await alert(job.buildId);\n }\n throw error;\n }\n await alert(job.buildId);\n },\n cancel: async (job) => {\n await inner.cancel?.(job);\n },\n ...(inner.close ? { close: inner.close } : {}),\n };\n}\n"],"mappings":";;;;;;;;;;;AAqDA,SAAS,YAAY,OAAyE;CAC5F,OAAO,OAAO,SAAS,UAAU;EAC/B,MAAM,cAAc,mBAAmB;EACvC,MAAM,MAAM,QAAQ;GAAE;GAAS;GAAO,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;EAAG,CAAC;CACjF;AACF;;;;;AAMA,SAAgB,aACd,SACA,QACA,UACA,QACA,MACe;CACf,MAAM,UAAU,QAAQ;CACxB,IAAI,YAAY,KAAA,GACd,OAAO;EAAE,OAAO;EAAM,gBAAgB,KAAA;EAAW,SAAS;CAAK;CAEjE,MAAM,QAAQ,KAAK;CACnB,MAAM,iBAAiB,YAAY,KAAK;CACxC,MAAM,iBAAiB,sBAAsB,SAAS,QAAQ,UAAU,QAAQ;EAC9E,QAAQ,QAAQ;EAChB,SAAS,OAAO,QAAQ;GACtB,MAAM,MAAM,QAAQ,GAAG;EACzB;CACF,CAAC;CACD,MAAM,UAAU,QAAQ,SAAS,WAAW;CAC5C,MAAM,WAAW,oBAAoB,gBAAgB,OAAO;CAC5D,MAAM,SAAS,OAAO,KAAkB,UAAiC;EACvE,MAAM,mBAAmB,UAAU,KAAK,KAAK;EAC7C,MAAM,oBAAoB,SAAS,QAAQ,QAAQ,IAAI,SAAS,SAAS,YAAY;CACvF;CACA,KAAK,UAAU,UAAU;CACzB,KAAK,UAAU,UAAU,OAAO,QAAuB;EACrD,MAAM,mBAAmB,SAAS,QAAQ,QAAQ,IAAI,SAAS,SAAS,YAAY;CACtF;CACA,MAAM,YAAY,qBAAqB,cAAc;CAIrD,OAAO;EACL;EACA;EACA,SAAS,SANK,kBAAkB,SAAS,MAAM,IAC7C,cAAc,SAAS;GAAE,UAAU;GAAW;GAAS,WAAW;GAAQ;EAAO,CAAC,IAClF,WAIyB,SAAS,OAAO,YAAY;GACrD,MAAM,oBAAoB,SAAS,QAAQ,QAAQ,SAAS,SAAS,YAAY;EACnF,CAAC;CACH;AACF;AAEA,SAAS,sBACP,SACA,QACA,UACA,QACA,OACuB;CACvB,OAAO;EACL,IAAI,QAAQ;EACZ,SAAS,QAAQ;EACjB,QAAQ,MAAM;EAEd,YAAY,OAAO,cAAc;EACjC,WAAW,OAAO;EAClB;EACA,QAAQ,eAAe,MAAM;EAC7B,QAAQ,QAAQ;EAChB,WAAW,QAAQ,SAAS;EAC5B,WAAW,SAAS;EACpB,YAAY,SAAS;EACrB,cAAc,SAAS;EACvB;EACA,SAAS,MAAM;EACf,YAAY,QAAQ,SAAS;CAC/B;AACF;AAEA,SAAS,cACP,SACA,OAMgB;CAChB,MAAM,UAAU,QAAQ;CACxB,OAAO,oBAAoB;EACzB,aAAa,SAAS,eAAe;EACrC,WAAW,SAAS;EACpB,aAAa,SAAS,QAAQ;EAC9B,aAAa,SAAS,SAAS;EAC/B,GAAG;CACL,CAAC;AACH;;;;;;AAOA,SAAS,SACP,OACA,SACA,OACgB;CAChB,OAAO;EACL,UAAU,QAAQ,MAAM,UAAU,GAAG,MAAM;EAC3C,KAAK,OAAO,KAAK,YAAY;GAC3B,IAAI;IACF,MAAM,MAAM,IAAI,KAAK,OAAO;GAC9B,SAAS,OAAO;IACd,IAAI,eAAe,KAAK,OAAO,GAC7B,MAAM,MAAM,IAAI,OAAO;IAEzB,MAAM;GACR;GACA,MAAM,MAAM,IAAI,OAAO;EACzB;EACA,QAAQ,OAAO,QAAQ;GACrB,MAAM,MAAM,SAAS,GAAG;EAC1B;EACA,GAAI,MAAM,QAAQ,EAAE,OAAO,MAAM,MAAM,IAAI,CAAC;CAC9C;AACF"}
|
package/dist/index.mjs
CHANGED
package/dist/job-queue.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { DATABASE_QUEUE_METADATA, createDatabaseCaptureQueue } from "@storyshelf
|
|
|
10
10
|
function createJobQueue(options, kinds, retries) {
|
|
11
11
|
let queue;
|
|
12
12
|
const exhausted = {};
|
|
13
|
+
const abandoned = {};
|
|
13
14
|
const get = () => {
|
|
14
15
|
queue ??= createDatabaseCaptureQueue(options.database, {
|
|
15
16
|
kinds: kinds.length > 0 ? kinds : ["capture"],
|
|
@@ -21,6 +22,9 @@ function createJobQueue(options, kinds, retries) {
|
|
|
21
22
|
},
|
|
22
23
|
onExhausted: async (job, error) => {
|
|
23
24
|
await exhausted[job.kind]?.(job, error);
|
|
25
|
+
},
|
|
26
|
+
onAbandoned: async (job, error) => {
|
|
27
|
+
await abandoned[job.kind]?.(job, error);
|
|
24
28
|
}
|
|
25
29
|
});
|
|
26
30
|
return queue;
|
|
@@ -30,7 +34,8 @@ function createJobQueue(options, kinds, retries) {
|
|
|
30
34
|
get,
|
|
31
35
|
built: () => queue,
|
|
32
36
|
view: queueView(get),
|
|
33
|
-
exhausted
|
|
37
|
+
exhausted,
|
|
38
|
+
abandoned
|
|
34
39
|
};
|
|
35
40
|
}
|
|
36
41
|
function queueView(get) {
|
package/dist/job-queue.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"job-queue.mjs","names":[],"sources":["../src/job-queue.ts"],"sourcesContent":["/**\n * The app's one job queue (ADR 0006, decision 4): the database queue, built on first use and\n * shared by every job kind. Capture, publish and notify jobs are enqueued onto it and consumed by\n * one consumer with a handler per kind. Building it lazily keeps hosts that never enqueue or\n * consume (OpenAPI generation, stub-table tests) from touching `capture_jobs`.\n */\nimport type { CaptureQueue } from \"@storyshelf/core/adapter/capture-queue\";\nimport {\n createDatabaseCaptureQueue,\n DATABASE_QUEUE_METADATA,\n type DatabaseCaptureQueue,\n type DatabaseJob,\n} from \"@storyshelf/core/capture\";\nimport type { ShelfOptions } from \"@storyshelf/core/config\";\nimport type { CaptureJobKind } from \"@storyshelf/core/schema\";\n\n/** What a kind does when a job of it failed because its last attempt lost its lease. */\nexport type ExhaustedHandler = (job: DatabaseJob, error: string) => Promise<void>;\n\n/** The shared database queue. */\nexport interface JobQueue {\n /** Job kinds this instance polls; empty when it consumes none. */\n kinds: readonly CaptureJobKind[];\n /** The queue, built on first use. */\n get(): DatabaseCaptureQueue;\n /** The queue if it was ever built. */\n built(): DatabaseCaptureQueue | undefined;\n /** Capture-shaped view for pages and the store; builds the queue only when called. */\n view: CaptureQueue;\n /** Per-kind hooks for exhausted jobs, filled in by the kind's setup. */\n exhausted: Partial<Record<CaptureJobKind, ExhaustedHandler>>;\n}\n\n/**\n * Create the lazy queue polling `kinds`, with `retries` after a first failed attempt, the lease\n * of `testing.leaseSeconds` and the clock of `testing.queueClock` (production: real time).\n */\nexport function createJobQueue(\n options: ShelfOptions,\n kinds: readonly CaptureJobKind[],\n retries: number,\n): JobQueue {\n let queue: DatabaseCaptureQueue | undefined;\n const exhausted: JobQueue[\"exhausted\"] = {};\n const get = (): DatabaseCaptureQueue => {\n queue ??= createDatabaseCaptureQueue(options.database, {\n kinds: kinds.length > 0 ? kinds : [\"capture\"],\n retries,\n ...(options.testing?.leaseSeconds === undefined\n ? {}\n : { leaseMs: Math.round(options.testing.leaseSeconds * 1000) }),\n ...(options.testing?.queueClock === undefined\n ? {}\n : { now: options.testing.queueClock.now, timers: options.testing.queueClock.timers }),\n onExhausted: async (job, error) => {\n await exhausted[job.kind]?.(job, error);\n },\n });\n return queue;\n };\n return { kinds, get, built: () => queue, view: queueView(get), exhausted };\n}\n\nfunction queueView(get: () => DatabaseCaptureQueue): CaptureQueue {\n return {\n metadata: DATABASE_QUEUE_METADATA,\n enqueue: async (job) => {\n await get().enqueue(job);\n },\n status: async (buildId) => await get().status(buildId),\n active: async () => await get().active(),\n recent: async (limit) => await get().recent(limit),\n cancel: async (id) => await get().cancel(id),\n };\n}\n"],"mappings":";;;;;;;;;
|
|
1
|
+
{"version":3,"file":"job-queue.mjs","names":[],"sources":["../src/job-queue.ts"],"sourcesContent":["/**\n * The app's one job queue (ADR 0006, decision 4): the database queue, built on first use and\n * shared by every job kind. Capture, publish and notify jobs are enqueued onto it and consumed by\n * one consumer with a handler per kind. Building it lazily keeps hosts that never enqueue or\n * consume (OpenAPI generation, stub-table tests) from touching `capture_jobs`.\n */\nimport type { CaptureQueue } from \"@storyshelf/core/adapter/capture-queue\";\nimport {\n createDatabaseCaptureQueue,\n DATABASE_QUEUE_METADATA,\n type DatabaseCaptureQueue,\n type DatabaseJob,\n} from \"@storyshelf/core/capture\";\nimport type { ShelfOptions } from \"@storyshelf/core/config\";\nimport type { CaptureJobKind } from \"@storyshelf/core/schema\";\n\n/** What a kind does when a job of it failed because its last attempt lost its lease. */\nexport type ExhaustedHandler = (job: DatabaseJob, error: string) => Promise<void>;\n\n/** The shared database queue. */\nexport interface JobQueue {\n /** Job kinds this instance polls; empty when it consumes none. */\n kinds: readonly CaptureJobKind[];\n /** The queue, built on first use. */\n get(): DatabaseCaptureQueue;\n /** The queue if it was ever built. */\n built(): DatabaseCaptureQueue | undefined;\n /** Capture-shaped view for pages and the store; builds the queue only when called. */\n view: CaptureQueue;\n /** Per-kind hooks for exhausted jobs, filled in by the kind's setup. */\n exhausted: Partial<Record<CaptureJobKind, ExhaustedHandler>>;\n /** Per-kind hooks for a job whose exhausted hook kept failing (the alert of last resort). */\n abandoned: Partial<Record<CaptureJobKind, ExhaustedHandler>>;\n}\n\n/**\n * Create the lazy queue polling `kinds`, with `retries` after a first failed attempt, the lease\n * of `testing.leaseSeconds` and the clock of `testing.queueClock` (production: real time).\n */\nexport function createJobQueue(\n options: ShelfOptions,\n kinds: readonly CaptureJobKind[],\n retries: number,\n): JobQueue {\n let queue: DatabaseCaptureQueue | undefined;\n const exhausted: JobQueue[\"exhausted\"] = {};\n const abandoned: JobQueue[\"abandoned\"] = {};\n const get = (): DatabaseCaptureQueue => {\n queue ??= createDatabaseCaptureQueue(options.database, {\n kinds: kinds.length > 0 ? kinds : [\"capture\"],\n retries,\n ...(options.testing?.leaseSeconds === undefined\n ? {}\n : { leaseMs: Math.round(options.testing.leaseSeconds * 1000) }),\n ...(options.testing?.queueClock === undefined\n ? {}\n : { now: options.testing.queueClock.now, timers: options.testing.queueClock.timers }),\n onExhausted: async (job, error) => {\n await exhausted[job.kind]?.(job, error);\n },\n onAbandoned: async (job, error) => {\n await abandoned[job.kind]?.(job, error);\n },\n });\n return queue;\n };\n return { kinds, get, built: () => queue, view: queueView(get), exhausted, abandoned };\n}\n\nfunction queueView(get: () => DatabaseCaptureQueue): CaptureQueue {\n return {\n metadata: DATABASE_QUEUE_METADATA,\n enqueue: async (job) => {\n await get().enqueue(job);\n },\n status: async (buildId) => await get().status(buildId),\n active: async () => await get().active(),\n recent: async (limit) => await get().recent(limit),\n cancel: async (id) => await get().cancel(id),\n };\n}\n"],"mappings":";;;;;;;;;AAuCA,SAAgB,eACd,SACA,OACA,SACU;CACV,IAAI;CACJ,MAAM,YAAmC,CAAC;CAC1C,MAAM,YAAmC,CAAC;CAC1C,MAAM,YAAkC;EACtC,UAAU,2BAA2B,QAAQ,UAAU;GACrD,OAAO,MAAM,SAAS,IAAI,QAAQ,CAAC,SAAS;GAC5C;GACA,GAAI,QAAQ,SAAS,iBAAiB,KAAA,IAClC,CAAC,IACD,EAAE,SAAS,KAAK,MAAM,QAAQ,QAAQ,eAAe,GAAI,EAAE;GAC/D,GAAI,QAAQ,SAAS,eAAe,KAAA,IAChC,CAAC,IACD;IAAE,KAAK,QAAQ,QAAQ,WAAW;IAAK,QAAQ,QAAQ,QAAQ,WAAW;GAAO;GACrF,aAAa,OAAO,KAAK,UAAU;IACjC,MAAM,UAAU,IAAI,KAAK,GAAG,KAAK,KAAK;GACxC;GACA,aAAa,OAAO,KAAK,UAAU;IACjC,MAAM,UAAU,IAAI,KAAK,GAAG,KAAK,KAAK;GACxC;EACF,CAAC;EACD,OAAO;CACT;CACA,OAAO;EAAE;EAAO;EAAK,aAAa;EAAO,MAAM,UAAU,GAAG;EAAG;EAAW;CAAU;AACtF;AAEA,SAAS,UAAU,KAA+C;CAChE,OAAO;EACL,UAAU;EACV,SAAS,OAAO,QAAQ;GACtB,MAAM,IAAI,CAAC,CAAC,QAAQ,GAAG;EACzB;EACA,QAAQ,OAAO,YAAY,MAAM,IAAI,CAAC,CAAC,OAAO,OAAO;EACrD,QAAQ,YAAY,MAAM,IAAI,CAAC,CAAC,OAAO;EACvC,QAAQ,OAAO,UAAU,MAAM,IAAI,CAAC,CAAC,OAAO,KAAK;EACjD,QAAQ,OAAO,OAAO,MAAM,IAAI,CAAC,CAAC,OAAO,EAAE;CAC7C;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storyshelf/app",
|
|
3
|
-
"version": "0.9.0
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "StoryShelf app: Hono app, API routes, and server-rendered UI over @storyshelf/core.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chromatic-alternative",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@hono/swagger-ui": "^0.6.1",
|
|
62
62
|
"@hono/zod-openapi": "^1.1.5",
|
|
63
|
-
"@storyshelf/core": "0.9.0
|
|
64
|
-
"@storyshelf/observability": "0.9.0
|
|
63
|
+
"@storyshelf/core": "0.9.0",
|
|
64
|
+
"@storyshelf/observability": "0.9.0",
|
|
65
65
|
"hono": "^4.11.0",
|
|
66
66
|
"zod": "^4.6.7"
|
|
67
67
|
},
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
"@opentelemetry/core": "^2.11.0",
|
|
72
72
|
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
|
73
73
|
"@storybook/html-vite": "10.6.5",
|
|
74
|
-
"@storyshelf/auth": "0.9.0
|
|
75
|
-
"@storyshelf/db-sqlite": "0.9.0
|
|
76
|
-
"@storyshelf/design-figma": "0.9.0
|
|
74
|
+
"@storyshelf/auth": "0.9.0",
|
|
75
|
+
"@storyshelf/db-sqlite": "0.9.0",
|
|
76
|
+
"@storyshelf/design-figma": "0.9.0",
|
|
77
77
|
"@types/node": "^26.0.0",
|
|
78
78
|
"@vitest/coverage-v8": "^4.0.0",
|
|
79
79
|
"adm-zip": "0.6.1",
|