@fabrika/app 0.3.2 → 0.4.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.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,749 @@
1
+ // The job runner (`fabrika job run <name>`): one run of one `c.cron` job. A run takes the job's lease or does not
2
+ // start; each attempt holds one generation of it with a deadline the database enforces, so a write of an attempt
3
+ // that timed out, failed or lost the lease never commits (`@fabrika/db`'s fence). A form's `then` kicks a run in the
4
+ // background without waiting for any lock; a kick that finds the lease held leaves the shared pending flag, and the run
5
+ // holding it runs once more after it succeeded. Failures, crashed runs and a job that stopped succeeding are reported
6
+ // to Operations, each under a fixed failure code.
7
+
8
+ import type { IamMembership } from '@fabrika/auth'
9
+ import { type ContractArtifact, JOB_SITE_PREFIX, type JobArtifact, type PermissionEntry, permits } from '@fabrika/auth-core'
10
+ import type { CapabilityAuthority } from '@fabrika/db/capability'
11
+ import { type DataAccess, type FrameworkTables, type JobLeases, type LeaseFence, LeaseLostError } from '@fabrika/db/framework'
12
+ import { NotFoundError } from '../errors'
13
+ import type { FileCollections } from '../files/collections'
14
+ import { type CapabilityMint, CapabilityRefusedError, type Ref, RequestFrame, runInRequest, type Within } from '../implement/capability'
15
+ import { failureCode, type FailureReason, failureReason } from '../implement/failures'
16
+ import { ServerFault } from '../implement/faults'
17
+ import type { ContractDatabase } from '../implement/implement'
18
+ import {
19
+ approvedDeclaration,
20
+ approvedReach,
21
+ boundTo,
22
+ isSiteHandler,
23
+ jobHandles,
24
+ jobSql,
25
+ jobSqlOutsideTransaction,
26
+ noFileCollections,
27
+ siteData,
28
+ type SiteDeclaration,
29
+ undeclaredSql,
30
+ } from '../implement/invocation'
31
+ import { jobLinkIssuing, type JobLinkMount } from '../implement/membership'
32
+ import type { WorkloadCredential } from '../implement/workload'
33
+ import type { OperationsReporter } from './report'
34
+ import { type CronSchedule, parseCron, periodAt } from './schedule'
35
+ import type { JobContext, JobPrincipal } from './types'
36
+
37
+ /** An attempt of a job without a `timeout` is cancelled after this long. */
38
+ export const DEFAULT_JOB_TIMEOUT_MS = 15 * 60_000
39
+ /**
40
+ * The lease outlives an attempt's deadline by this much: the runner renews or finishes a timed-out attempt's lease,
41
+ * and no other run takes it over meanwhile. The attempt's writes stop at its deadline regardless.
42
+ */
43
+ export const LEASE_GRACE_MS = 60_000
44
+ /** How often the serving process checks every job for a crashed run and for a schedule that stopped. */
45
+ export const JOB_CHECK_INTERVAL_MS = 60_000
46
+ const BACKOFF_BASE_MS = 1_000
47
+ const BACKOFF_MAX_MS = 5 * 60_000
48
+ const DURATION = /^([1-9][0-9]*)([smh])$/
49
+ const UNIT_MS: Readonly<Record<string, number>> = { s: 1_000, m: 60_000, h: 3_600_000 }
50
+
51
+ /**
52
+ * Why a run failed. Fixed codes only: an application error's name and message may carry a secret, so neither is
53
+ * ever logged, returned or reported.
54
+ */
55
+ export type JobFailureCode =
56
+ | 'timeout'
57
+ | 'lease_lost'
58
+ | 'handler_failed'
59
+ /** The data layer refused a statement or an operation tree (`QueryRefusedError`): its reason names why. */
60
+ | 'query_refused'
61
+ /** A capability or a handle was refused: forged, ended, of other work, or of another kind than the call takes. */
62
+ | 'capability_refused'
63
+ | 'driver_failed'
64
+ | 'crashed'
65
+ /** IAM refused the job's key: the attempt mints no capability. */
66
+ | 'unauthorized'
67
+ /** IAM could not be asked for the job's token. */
68
+ | 'iam_unavailable'
69
+
70
+ export type JobRunResult =
71
+ | { readonly status: 'succeeded'; readonly attempts: number }
72
+ /** `reason` is a framework refusal's catalog key (`query_refused`, `capability_refused`), present only for one. */
73
+ | { readonly status: 'failed'; readonly attempts: number; readonly code: JobFailureCode; readonly reason?: string }
74
+ /** Another run holds the lease: an overlapping run does not start. */
75
+ | { readonly status: 'skipped' }
76
+
77
+ /** The runner's waits; the lease's times are always the database's. */
78
+ export interface JobClock {
79
+ /** Resolves after `ms`, or as soon as `signal` aborts. */
80
+ wait(ms: number, signal: AbortSignal): Promise<void>
81
+ /** In [0, 1): the jitter of a retry's backoff. */
82
+ random(): number
83
+ }
84
+
85
+ export interface JobRunOptions {
86
+ readonly clock?: JobClock
87
+ /** Where failures and missed runs go; by default the one `boot` built from `FABRIKA_OPERATIONS_DSN`. */
88
+ readonly reporter?: OperationsReporter
89
+ }
90
+
91
+ /**
92
+ * A job the framework runs itself under a reserved name (`FILE_SCAN_JOB`): no handler, no key, no capability, one
93
+ * attempt. It holds its lease like an application job, and every write of its run is fenced by it.
94
+ */
95
+ export interface FrameworkJob {
96
+ readonly name: string
97
+ readonly schedule: string
98
+ readonly timeoutMs: number
99
+ /** One run; `signal` aborts when the attempt's deadline passes. A throw fails the run. */
100
+ run(reporter: OperationsReporter, signal: AbortSignal): Promise<void>
101
+ }
102
+
103
+ /** The contract's jobs, bound to their handlers by `boot`, and the framework's own. */
104
+ export interface ContractJobs {
105
+ /** The contract's `c.cron` jobs; `run` also takes the name of a framework job the contract needs. */
106
+ readonly names: readonly string[]
107
+ run(name: string, options?: JobRunOptions): Promise<JobRunResult>
108
+ /**
109
+ * Registers every job (its first-seen time), reports a run that crashed and a job that has not succeeded within
110
+ * twice its period. The serving process calls it every `JOB_CHECK_INTERVAL_MS`; it never throws.
111
+ */
112
+ check(options?: Pick<JobRunOptions, 'reporter'>): Promise<void>
113
+ }
114
+
115
+ /** A run that cannot start: an unknown job. Its message is framework-authored. */
116
+ export class JobError extends Error {
117
+ override readonly name = 'JobError'
118
+ }
119
+
120
+ export const systemClock: JobClock = Object.freeze({
121
+ wait: (ms: number, signal: AbortSignal) =>
122
+ new Promise<void>((resolve) => {
123
+ if (signal.aborted) {
124
+ resolve()
125
+ return
126
+ }
127
+ const timer = setTimeout(resolve, ms)
128
+ signal.addEventListener('abort', () => {
129
+ clearTimeout(timer)
130
+ resolve()
131
+ }, { once: true })
132
+ }),
133
+ random: () => Math.random(),
134
+ })
135
+
136
+ interface BoundJob {
137
+ readonly name: string
138
+ readonly artifact: JobArtifact
139
+ readonly schedule: CronSchedule
140
+ readonly timeoutMs: number
141
+ readonly handler: (args: { readonly ctx: object }) => unknown
142
+ readonly credential: WorkloadCredential
143
+ /** A staged-kit job's declaration: it reads through `ctx.db` and writes only in `ctx.transaction`. */
144
+ readonly site: SiteDeclaration | null
145
+ /** The link mount the approved artifact says the job `issues`: its `ctx.links`. */
146
+ readonly links: JobLinkMount | null
147
+ }
148
+
149
+ interface RunnerContext {
150
+ readonly artifact: ContractArtifact
151
+ readonly database: ContractDatabase | null
152
+ readonly ownedDimensions: ReadonlySet<string>
153
+ readonly leases: JobLeases
154
+ readonly reporter: OperationsReporter
155
+ readonly mint: CapabilityMint
156
+ readonly data: DataAccess | null
157
+ readonly files: FileCollections | null
158
+ readonly membership: IamMembership | null
159
+ readonly capabilities: CapabilityAuthority
160
+ readonly containers: ReadonlyMap<string, readonly string[]>
161
+ }
162
+
163
+ export interface JobBindingInput {
164
+ readonly artifact: ContractArtifact
165
+ /** The handler tree `boot` binds; a job's handler is `handlers.jobs.<name>`. */
166
+ readonly handlers: unknown
167
+ readonly database: ContractDatabase | null
168
+ /** The framework side of `@fabrika/db`, bound by `boot`; `null` when the application has no such layer. */
169
+ readonly framework: FrameworkTables | null
170
+ readonly ownedDimensions: ReadonlySet<string>
171
+ /** Where failures and missed runs go: Operations, or the server log without a DSN. */
172
+ readonly reporter: OperationsReporter
173
+ /** `job:<name>` → the job's key; `boot` refuses a job without one. */
174
+ readonly credentials: ReadonlyMap<string, WorkloadCredential>
175
+ /** The runtime's capability mint, which `boot` alone holds. */
176
+ readonly mint: CapabilityMint
177
+ /** The mini-ORM of the bound `scoped(tables)` layer, behind a staged-kit job's `ctx.db`. */
178
+ readonly data: DataAccess | null
179
+ /** The contract's file collections, behind a staged-kit job's `ctx.files` and `tx.files`; `null` without any. */
180
+ readonly files?: FileCollections | null
181
+ /** The framework's own jobs this contract needs: the scan of its `scan: true` file collections. */
182
+ readonly frameworkJobs?: readonly FrameworkJob[]
183
+ /** IAM's membership operations, which a job that `issues` links asks; `null` when the adapter supplies none. */
184
+ readonly membership: IamMembership | null
185
+ /** The runtime's authority, which a `Ref` passed to IAM must resolve with. */
186
+ readonly capabilities: CapabilityAuthority
187
+ /** Each dimension's containers, nearest first: a link's chain. */
188
+ readonly containers: ReadonlyMap<string, readonly string[]>
189
+ }
190
+
191
+ interface BoundFrameworkJob {
192
+ readonly job: FrameworkJob
193
+ readonly name: string
194
+ readonly schedule: CronSchedule
195
+ }
196
+
197
+ export interface BoundJobs {
198
+ readonly jobs: ContractJobs
199
+ /** The handler ids the jobs took (`jobs.<name>`), so `boot` finds no undeclared handler among them. */
200
+ readonly declared: readonly string[]
201
+ /**
202
+ * Kicks the contract job `name`, as a form's `then` does after its row committed: a run under the job's own key
203
+ * now, or the shared pending flag for the run holding the lease, which then runs once more after it succeeded. It
204
+ * waits for no lock, settles when that work has ended and never rejects; the schedule stays the recovery path.
205
+ */
206
+ readonly kick: (name: string) => Promise<void>
207
+ }
208
+
209
+ function ownChild(node: unknown, key: string): unknown {
210
+ return typeof node === 'object' && node !== null && Object.hasOwn(node, key) ? Reflect.get(node, key) : undefined
211
+ }
212
+
213
+ function durationMs(timeout: string | null): number | null {
214
+ if (timeout === null) {
215
+ return DEFAULT_JOB_TIMEOUT_MS
216
+ }
217
+ const match = DURATION.exec(timeout)
218
+ const unit = match?.[2] === undefined ? undefined : UNIT_MS[match[2]]
219
+ return match?.[1] === undefined || unit === undefined ? null : Number.parseInt(match[1], 10) * unit
220
+ }
221
+
222
+ /** Binds every declared job to its handler, or adds why it cannot run to `problems`. */
223
+ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs {
224
+ const entries = Object.entries(input.artifact.jobs)
225
+ const bound = new Map<string, BoundJob>()
226
+ const declared: string[] = []
227
+ const tree = ownChild(input.handlers, 'jobs')
228
+ for (const [name, artifact] of entries) {
229
+ declared.push(`jobs.${name}`)
230
+ const where = `job '${name}'`
231
+ if (artifact.scope.kind !== 'app') {
232
+ problems.push(`${where} is scoped to '${artifact.scope.dimension}'; this version runs jobs with scope: 'app' only`)
233
+ }
234
+ if (input.framework === null) {
235
+ problems.push(`${where} needs @fabrika/db on Postgres, which holds its lease and fences its writes: serve(contract, { database: scoped(tables) })`)
236
+ }
237
+ const schedule = parseCron(artifact.schedule)
238
+ if (schedule === null) {
239
+ problems.push(`${where} has the schedule '${artifact.schedule}', which is not a cron expression (allowed: '0 3 * * *', '*/15 * * * *')`)
240
+ }
241
+ const timeoutMs = durationMs(artifact.timeout)
242
+ if (timeoutMs === null) {
243
+ problems.push(`${where} has the timeout '${artifact.timeout}', which is not a duration (allowed: '30s', '10m', '1h')`)
244
+ }
245
+ const node = ownChild(tree, name)
246
+ const handler = isSiteHandler(node) ? node.handler : node
247
+ const site = isSiteHandler(node) ? approvedDeclaration(where, `${JOB_SITE_PREFIX}${name}`, node.declared, 'job', input.artifact, problems) : null
248
+ const credential = input.credentials.get(`job:${name}`)
249
+ const links = jobLinks(where, `${JOB_SITE_PREFIX}${name}`, site, input, problems)
250
+ if (typeof handler !== 'function') {
251
+ problems.push(`${where} has no handler; add it to handlers.jobs`)
252
+ } else if (site !== null && input.data === null) {
253
+ problems.push(`${where} reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
254
+ } else if (schedule !== null && timeoutMs !== null && credential !== undefined) {
255
+ bound.set(name, { name, artifact, schedule, timeoutMs, handler: (args) => Reflect.apply(handler, undefined, [args]), credential, site, links })
256
+ }
257
+ }
258
+ const frameworkJobs = new Map<string, BoundFrameworkJob>()
259
+ for (const job of input.frameworkJobs ?? []) {
260
+ const schedule = parseCron(job.schedule)
261
+ if (input.framework === null) {
262
+ problems.push(`the framework job '${job.name}' needs @fabrika/db on Postgres, which holds its lease and fences its writes`)
263
+ } else if (schedule !== null) {
264
+ frameworkJobs.set(job.name, { job, name: job.name, schedule })
265
+ }
266
+ }
267
+ const names = Object.freeze(entries.map(([name]) => name))
268
+ const framework = input.framework
269
+ const contextFor = (leases: JobLeases, chosen: OperationsReporter | undefined): RunnerContext => ({
270
+ artifact: input.artifact,
271
+ database: input.database,
272
+ ownedDimensions: input.ownedDimensions,
273
+ leases,
274
+ reporter: chosen ?? input.reporter,
275
+ mint: input.mint,
276
+ data: input.data,
277
+ files: input.files ?? null,
278
+ membership: input.membership,
279
+ capabilities: input.capabilities,
280
+ containers: input.containers,
281
+ })
282
+ const jobs: ContractJobs = Object.freeze({
283
+ names,
284
+ run(name: string, options: JobRunOptions = {}): Promise<JobRunResult> {
285
+ const own = frameworkJobs.get(name)
286
+ if (own !== undefined && framework !== null) {
287
+ return runFrameworkJob(own, contextFor(framework.jobs, options.reporter), options.clock ?? systemClock)
288
+ }
289
+ const job = bound.get(name)
290
+ if (job === undefined || framework === null) {
291
+ throw new JobError(`no job '${name}' in the contract (jobs: ${names.length === 0 ? 'none' : names.join(', ')})`)
292
+ }
293
+ return runJob(job, contextFor(framework.jobs, options.reporter), options.clock ?? systemClock, 'wait').then((result) => {
294
+ if (result.status === 'skipped') {
295
+ console.error(`fabrika: a previous run of job '${name}' still holds its lease; this run does not start`)
296
+ }
297
+ return result
298
+ })
299
+ },
300
+ async check(options: Pick<JobRunOptions, 'reporter'> = {}): Promise<void> {
301
+ if (framework === null) {
302
+ return
303
+ }
304
+ const context = contextFor(framework.jobs, options.reporter)
305
+ for (const job of [...bound.values(), ...frameworkJobs.values()]) {
306
+ try {
307
+ await context.leases.register(job.name)
308
+ await surveil(job, context)
309
+ } catch {
310
+ // A driver error may quote the database URL.
311
+ console.error(`fabrika: the check of job '${job.name}' failed: driver_failed`)
312
+ }
313
+ }
314
+ },
315
+ })
316
+ const kick = async (name: string): Promise<void> => {
317
+ const job = bound.get(name)
318
+ if (job !== undefined && framework !== null) {
319
+ await kickOnce(job, contextFor(framework.jobs, undefined))
320
+ }
321
+ }
322
+ return { jobs, declared, kick }
323
+ }
324
+
325
+ /**
326
+ * One kick: a run while the lease is free, else the shared pending flag. Neither waits for a lock, so a kick never
327
+ * holds a connection requests share while a run writes. After a failed run the flag stays for the schedule.
328
+ */
329
+ async function kickOnce(job: BoundJob, context: RunnerContext): Promise<void> {
330
+ if ((await runJob(job, context, systemClock, 'try')).status === 'skipped') {
331
+ await handOver(job, context, systemClock)
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Leaves the flag to the run holding the lease, which takes it after it succeeded. That run may have finished before
337
+ * the flag was set, so the lease is tried once more; `null` when another run still holds it.
338
+ */
339
+ async function handOver(job: BoundJob, context: RunnerContext, clock: JobClock): Promise<JobRunResult | null> {
340
+ if (!(await markPending(job, context))) {
341
+ return null
342
+ }
343
+ const result = await runJob(job, context, clock, 'try')
344
+ return result.status === 'skipped' ? null : result
345
+ }
346
+
347
+ async function markPending(job: BoundJob, context: RunnerContext): Promise<boolean> {
348
+ try {
349
+ await context.leases.markPending(job.name)
350
+ return true
351
+ } catch {
352
+ // A driver error may quote the database URL; the schedule picks the submission up.
353
+ console.error(`fabrika: the kick of job '${job.name}' failed: driver_failed`)
354
+ return false
355
+ }
356
+ }
357
+
358
+ /**
359
+ * The link mount a job issues, by the approved artifact's declaration of its site whatever its leaf; a job that issues
360
+ * without an IAM membership transport does not boot.
361
+ */
362
+ function jobLinks(where: string, site: string, declaration: SiteDeclaration | null, input: JobBindingInput, problems: string[]): JobLinkMount | null {
363
+ const reach = declaration ?? (input.artifact.declared === undefined ? null : approvedReach(site, input.artifact))
364
+ const name = reach?.issues ?? null
365
+ if (name === null) {
366
+ return null
367
+ }
368
+ const mount = Object.hasOwn(input.artifact.mounts, name) ? input.artifact.mounts[name] : undefined
369
+ if (mount?.kind !== 'link') {
370
+ problems.push(`${where} issues '${name}', which is not a link mount of the contract`)
371
+ return null
372
+ }
373
+ if (input.membership === null) {
374
+ problems.push(
375
+ `${where} issues links of '${name}', and the runtime adapter supplies no IAM membership transport (the IAM binding on Cloudflare; FABRIKA_IAM_RPC_URL and FABRIKA_IAM_RPC_KEY on Bun)`,
376
+ )
377
+ return null
378
+ }
379
+ return { name, prefix: mount.prefix, dimension: mount.dimension, issuedBy: mount.issuedBy }
380
+ }
381
+
382
+ const attemptId = (): string => Bun.randomUUIDv7()
383
+
384
+ /** Exponential from one second, capped at five minutes, with half of it jittered. */
385
+ function backoffMs(attempt: number, random: number): number {
386
+ const ceiling = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1))
387
+ return Math.round(ceiling / 2 + (ceiling / 2) * random)
388
+ }
389
+
390
+ /** A crashed run (its lease expired before it finished) and a job overdue by twice its period: one report each. */
391
+ async function surveil(job: { readonly name: string; readonly schedule: CronSchedule }, context: RunnerContext): Promise<void> {
392
+ const tags = { job: job.name }
393
+ if (await context.leases.reapCrashed(job.name)) {
394
+ const message = `job '${job.name}' failed: crashed (its run's lease expired before the run finished)`
395
+ console.error(`fabrika: ${message}`)
396
+ await context.reporter.report({ fingerprint: ['fabrika-job-failed', job.name], type: 'JobFailed', message, tags: { ...tags, code: 'crashed' } })
397
+ }
398
+ const period = periodAt(job.schedule, await context.leases.now())
399
+ const claim = period === null ? null : await context.leases.claimOverdue(job.name, period)
400
+ if (claim === null || period === null) {
401
+ return
402
+ }
403
+ const last = claim.lastSucceededAt === null
404
+ ? `has never succeeded since it was first seen at ${claim.firstSeenAt.toISOString()}`
405
+ : `last succeeded at ${claim.lastSucceededAt.toISOString()}`
406
+ await context.reporter.report({
407
+ fingerprint: ['fabrika-job-missed', job.name],
408
+ type: 'JobMissed',
409
+ message: `job '${job.name}' ${last}; it is due every ${Math.round(period / 60_000)} min and has missed twice that`,
410
+ tags,
411
+ })
412
+ }
413
+
414
+ interface Progress {
415
+ attempts: number
416
+ }
417
+
418
+ /**
419
+ * How a run takes the lease: `wait` is `fabrika job run`'s, in a process of its own; `try` is a kick's and a follow-up
420
+ * run's, which never wait for a lock.
421
+ */
422
+ type Acquisition = 'wait' | 'try'
423
+
424
+ /** A run of the job, then one more while a kick arrived after the last one started and it succeeded. */
425
+ async function runJob(job: BoundJob, context: RunnerContext, clock: JobClock, acquisition: Acquisition): Promise<JobRunResult> {
426
+ let result = await runOnce(job, context, clock, acquisition)
427
+ while (result.status === 'succeeded' && (await pendingTaken(job, context))) {
428
+ const next = await runOnce(job, context, clock, 'try')
429
+ if (next.status !== 'skipped') {
430
+ result = next
431
+ continue
432
+ }
433
+ // A newer run holds the lease and may have read before the kick's row: the flag goes back to it.
434
+ const handed = await handOver(job, context, clock)
435
+ return handed ?? result
436
+ }
437
+ return result
438
+ }
439
+
440
+ async function pendingTaken(job: BoundJob, context: RunnerContext): Promise<boolean> {
441
+ try {
442
+ return await context.leases.takePending(job.name)
443
+ } catch {
444
+ // A driver error may quote the database URL; the flag stays for the next run.
445
+ console.error(`fabrika: the pending run of job '${job.name}' could not be read: driver_failed`)
446
+ return false
447
+ }
448
+ }
449
+
450
+ async function runOnce(job: BoundJob, context: RunnerContext, clock: JobClock, acquisition: Acquisition): Promise<JobRunResult> {
451
+ const progress: Progress = { attempts: 0 }
452
+ try {
453
+ return await runLeased(job, context, clock, progress, acquisition)
454
+ } catch {
455
+ // Only the lease store throws here: the handler's errors stay inside its attempt. Its message may quote the URL.
456
+ return failed(job, progress.attempts, failure('driver_failed'), context.reporter)
457
+ }
458
+ }
459
+
460
+ async function runLeased(
461
+ job: BoundJob,
462
+ context: RunnerContext,
463
+ clock: JobClock,
464
+ progress: Progress,
465
+ acquisition: Acquisition,
466
+ ): Promise<JobRunResult> {
467
+ const { leases } = context
468
+ await leases.register(job.name)
469
+ const leaseMs = job.timeoutMs + LEASE_GRACE_MS
470
+ let fence: LeaseFence | null
471
+ if (acquisition === 'wait') {
472
+ await surveil(job, context)
473
+ fence = await leases.acquire(job.name, attemptId(), job.timeoutMs, leaseMs)
474
+ } else {
475
+ fence = await leases.tryAcquire(job.name, attemptId(), job.timeoutMs, leaseMs)
476
+ }
477
+ if (fence === null) {
478
+ return { status: 'skipped' }
479
+ }
480
+ // This run covers every kick before it: their rows committed before it reads.
481
+ await pendingTaken(job, context)
482
+ const attempts = job.artifact.retries + 1
483
+ progress.attempts = 1
484
+ let outcome = await attemptOnce(job, fence, progress.attempts, clock, context)
485
+ while (outcome !== null && progress.attempts < attempts) {
486
+ console.error(`fabrika: job '${job.name}' attempt ${progress.attempts} of ${attempts} failed: ${describeFailure(outcome)}`)
487
+ const delay = backoffMs(progress.attempts, clock.random())
488
+ // A new generation at once: nothing the failed attempt still has in flight commits from here on.
489
+ const next = await leases.renew(fence, attemptId(), delay + job.timeoutMs, delay + leaseMs)
490
+ if (next === null) {
491
+ return failed(job, progress.attempts, failure('lease_lost'), context.reporter)
492
+ }
493
+ fence = next
494
+ await clock.wait(delay, new AbortController().signal)
495
+ progress.attempts++
496
+ outcome = await attemptOnce(job, fence, progress.attempts, clock, context)
497
+ }
498
+ if (outcome === null) {
499
+ if (await leases.finish(fence, 'succeeded')) {
500
+ return { status: 'succeeded', attempts: progress.attempts }
501
+ }
502
+ return failed(job, progress.attempts, failure('lease_lost'), context.reporter)
503
+ }
504
+ await leases.finish(fence, 'failed')
505
+ return failed(job, progress.attempts, outcome, context.reporter)
506
+ }
507
+
508
+ /** Why an attempt or a run failed: its code and, for a framework refusal, its reason and the reason's catalog text. */
509
+ interface JobFailure {
510
+ readonly code: JobFailureCode
511
+ readonly why: FailureReason | null
512
+ }
513
+
514
+ const failure = (code: JobFailureCode): JobFailure => ({ code, why: null })
515
+
516
+ /** The code, then a refusal's reason and its fixed text: the same words a request's log line uses. */
517
+ const describeFailure = ({ code, why }: JobFailure): string => (why === null ? code : `${code}: ${why.reason}: ${why.text}`)
518
+
519
+ async function failed(job: { readonly name: string }, attempts: number, outcome: JobFailure, reporter: OperationsReporter): Promise<JobRunResult> {
520
+ const { code, why } = outcome
521
+ const message = `job '${job.name}' failed after ${attempts} attempt${attempts === 1 ? '' : 's'}: ${code}`
522
+ console.error(`fabrika: job '${job.name}' failed after ${attempts} attempt${attempts === 1 ? '' : 's'}: ${describeFailure(outcome)}`)
523
+ const tags: Record<string, string> = { job: job.name, code }
524
+ if (why !== null) {
525
+ tags['reason'] = why.reason
526
+ }
527
+ await reporter.report({ fingerprint: ['fabrika-job-failed', job.name], type: 'JobFailed', message, tags })
528
+ return why === null ? { status: 'failed', attempts, code } : { status: 'failed', attempts, code, reason: why.reason }
529
+ }
530
+
531
+ const TIMED_OUT: unique symbol = Symbol('fabrika.job.timedOut')
532
+
533
+ /** One run of a framework job: its lease, one attempt in a frame fenced by it, and the outcome reported like a job's. */
534
+ async function runFrameworkJob(bound: BoundFrameworkJob, context: RunnerContext, clock: JobClock): Promise<JobRunResult> {
535
+ const { leases } = context
536
+ let fence: LeaseFence | null
537
+ try {
538
+ await leases.register(bound.name)
539
+ await surveil(bound, context)
540
+ fence = await leases.acquire(bound.name, attemptId(), bound.job.timeoutMs, bound.job.timeoutMs + LEASE_GRACE_MS)
541
+ } catch {
542
+ return failed(bound, 0, failure('driver_failed'), context.reporter)
543
+ }
544
+ if (fence === null) {
545
+ console.error(`fabrika: a previous run of job '${bound.name}' still holds its lease; this run does not start`)
546
+ return { status: 'skipped' }
547
+ }
548
+ const frame = new RequestFrame(fence.holder, { fence })
549
+ const cancel = new AbortController()
550
+ const timer = new AbortController()
551
+ let outcome: JobFailure | null
552
+ try {
553
+ const ran = runInRequest(frame, () => bound.job.run(context.reporter, cancel.signal)).then(() => null, failureOf)
554
+ const timeout = clock.wait(bound.job.timeoutMs, timer.signal).then((): typeof TIMED_OUT => TIMED_OUT)
555
+ const result = await Promise.race([ran, timeout])
556
+ outcome = result === TIMED_OUT ? failure('timeout') : result
557
+ } finally {
558
+ frame.end()
559
+ timer.abort()
560
+ }
561
+ try {
562
+ // Writes are refused before the run hears it was stopped: a scanner that answers on abort commits nothing.
563
+ try {
564
+ await leases.cancelInFlight(fence)
565
+ } finally {
566
+ cancel.abort()
567
+ }
568
+ const finished = await leases.finish(fence, outcome === null ? 'succeeded' : 'failed')
569
+ if (outcome === null) {
570
+ return finished ? { status: 'succeeded', attempts: 1 } : failed(bound, 1, failure('lease_lost'), context.reporter)
571
+ }
572
+ } catch {
573
+ return failed(bound, 1, failure('driver_failed'), context.reporter)
574
+ }
575
+ return failed(bound, 1, outcome, context.reporter)
576
+ }
577
+
578
+ /**
579
+ * `null` when the handler finished, else the attempt's failure code. Either way the attempt's frame ends and the
580
+ * statements it still has running are cancelled before the runner touches the lease again.
581
+ */
582
+ async function attemptOnce(
583
+ job: BoundJob,
584
+ fence: LeaseFence,
585
+ attempt: number,
586
+ clock: JobClock,
587
+ context: RunnerContext,
588
+ ): Promise<JobFailure | null> {
589
+ // The job's authority is what IAM signs for its key at the start of the attempt; a revoked key stops the next one.
590
+ const authority = await job.credential.authority()
591
+ if (!authority.ok) {
592
+ return failure(authority.reason === 'unavailable' ? 'iam_unavailable' : 'unauthorized')
593
+ }
594
+ const frame = new RequestFrame(fence.holder, { fence })
595
+ const cancel = new AbortController()
596
+ const timer = new AbortController()
597
+ const ctx = jobContext(job, frame, attempt, cancel.signal, context, authority.perms)
598
+ try {
599
+ const ran = runInRequest(frame, async () => {
600
+ await job.handler({ ctx })
601
+ }).then(() => null, failureOf)
602
+ const timeout = clock.wait(job.timeoutMs, timer.signal).then((): typeof TIMED_OUT => TIMED_OUT)
603
+ const result = await Promise.race([ran, timeout])
604
+ return result === TIMED_OUT ? failure('timeout') : result
605
+ } finally {
606
+ frame.end()
607
+ cancel.abort()
608
+ timer.abort()
609
+ await context.leases.cancelInFlight(fence)
610
+ }
611
+ }
612
+
613
+ /**
614
+ * By class, never by name: an application error can call itself anything. A refusal is classified as a request's is
615
+ * (`failureCode`), so both name it alike; every other thrown value is `handler_failed`.
616
+ */
617
+ function failureOf(error: unknown): JobFailure {
618
+ if (error instanceof LeaseLostError) {
619
+ return failure('lease_lost')
620
+ }
621
+ const code = failureCode(error)
622
+ return code === 'query_refused' || code === 'capability_refused' ? { code, why: failureReason(error) } : failure('handler_failed')
623
+ }
624
+
625
+ function jobContext(
626
+ job: BoundJob,
627
+ frame: RequestFrame,
628
+ attempt: number,
629
+ signal: AbortSignal,
630
+ context: RunnerContext,
631
+ perms: readonly PermissionEntry[],
632
+ ): JobContext<string, string> {
633
+ const principal: JobPrincipal = Object.freeze({ kind: 'job', id: `job:${job.name}`, job: job.name })
634
+ const check = (action: string, dimension: string): void => {
635
+ if (frame.ended) {
636
+ throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it mints no capability`)
637
+ }
638
+ if (!job.artifact.actions.includes(action)) {
639
+ throw new CapabilityRefusedError(
640
+ 'undeclared_action',
641
+ `job '${job.name}' does not declare this action (actions: ${job.artifact.actions.join(', ')})`,
642
+ )
643
+ }
644
+ if (!permits([...perms], action)) {
645
+ throw new CapabilityRefusedError('undeclared_action', `the token IAM signed for the key of job '${job.name}' does not carry this action`)
646
+ }
647
+ if (!Object.hasOwn(context.artifact.dimensions, dimension)) {
648
+ throw new CapabilityRefusedError('undeclared_dimension', `job '${job.name}' named a dimension the contract does not declare`)
649
+ }
650
+ }
651
+ return Object.freeze({
652
+ principal,
653
+ runId: frame.id,
654
+ attempt,
655
+ signal,
656
+ ...(job.site === null || context.data === null ? {} : kitJobContext(frame, context.data, context.files, job.name, job.site, principal.id)),
657
+ ...(job.links === null || context.membership === null ? {} : { links: jobLinksOf(job, job.links, context.membership, frame, context) }),
658
+ within<const Act extends string, const Dim extends string>(action: Act, dimension: Dim): Within<Dim, Act> {
659
+ check(action, dimension)
660
+ return context.mint.within(frame, dimension, action, principal.id, 'all')
661
+ },
662
+ async ref<const Act extends string, const Dim extends string>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>> {
663
+ check(action, dimension)
664
+ if (typeof id !== 'string' || id === '') {
665
+ throw new CapabilityRefusedError('missing_id', `job '${job.name}' named an object without an id`)
666
+ }
667
+ const database = context.database
668
+ const chain = database === null || !context.ownedDimensions.has(dimension) ? new Map<string, string>() : await database.locate(dimension, id)
669
+ if (chain === null) {
670
+ throw new NotFoundError('Not found')
671
+ }
672
+ return context.mint.ref(frame, dimension, action, principal.id, id, chain)
673
+ },
674
+ })
675
+ }
676
+
677
+ /** `ctx.links` of a job that issues: IAM is asked with the job's current token, under the attempt's id. */
678
+ function jobLinksOf(job: BoundJob, mount: JobLinkMount, iam: IamMembership, frame: RequestFrame, context: RunnerContext): object {
679
+ // An attempt that timed out or lost its lease asks IAM nothing: its replacement may already have acted.
680
+ const assertLive = (): void => {
681
+ if (frame.ended) {
682
+ throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it asks IAM for no link`)
683
+ }
684
+ }
685
+ return jobLinkIssuing({
686
+ iam,
687
+ job: job.name,
688
+ mount,
689
+ containers: context.containers.get(mount.dimension) ?? [],
690
+ capabilities: context.capabilities,
691
+ assertLive,
692
+ async caller() {
693
+ assertLive()
694
+ const authority = await job.credential.authority()
695
+ if (!authority.ok) {
696
+ throw new ServerFault(
697
+ authority.reason === 'unavailable' ? 'iam_unavailable' : 'workload_unauthorized',
698
+ `IAM signs no token for the key of job '${job.name}'`,
699
+ 503,
700
+ )
701
+ }
702
+ return { credential: authority.token, requestId: frame.id }
703
+ },
704
+ })
705
+ }
706
+
707
+ /**
708
+ * What a staged-kit job adds: `ctx.db`, whose reads run each on its own, and `ctx.transaction(work)`, the one place it
709
+ * writes and runs `tx.sql` — fenced by the attempt's lease, so an attempt that timed out or lost it commits nothing.
710
+ */
711
+ function kitJobContext(
712
+ frame: RequestFrame,
713
+ data: DataAccess,
714
+ files: FileCollections | null,
715
+ name: string,
716
+ site: SiteDeclaration,
717
+ principal: string,
718
+ ): Readonly<Record<string, unknown>> {
719
+ const reads = jobHandles(frame, data, site, principal, 'job', async () => data.direct(), () => false)
720
+ const job = `job '${name}'`
721
+ return {
722
+ db: siteData(reads),
723
+ sql: site.sql === null ? undeclaredSql(job, 'job') : jobSqlOutsideTransaction(job),
724
+ files: files === null ? noFileCollections(job) : files.site({
725
+ where: job,
726
+ frame,
727
+ check: boundTo(frame, () => false, 'ctx.files'),
728
+ session: async () => data.direct(),
729
+ readOnly: 'a job removes files inside ctx.transaction((tx) => …), as tx.files',
730
+ }),
731
+ transaction: async <R>(work: (tx: object) => Promise<R>): Promise<R> => {
732
+ // Outside its attempt there is no lease to fence the transaction with, so it never opens.
733
+ boundTo(frame, () => false)()
734
+ return data.transaction(async (session) => {
735
+ let open = true
736
+ const writes = jobHandles(frame, data, site, principal, 'mutation', async () => session, () => !open)
737
+ try {
738
+ const sql = site.sql === null ? undeclaredSql(job, 'job', 'tx.sql') : jobSql(frame, data, site.sql, session, () => !open)
739
+ const removing = files === null
740
+ ? noFileCollections(job)
741
+ : files.site({ where: job, frame, check: boundTo(frame, () => !open, 'tx.files'), session: async () => session, readOnly: null })
742
+ return await work(Object.freeze({ db: siteData(writes), sql, files: removing }))
743
+ } finally {
744
+ open = false
745
+ }
746
+ })
747
+ },
748
+ }
749
+ }