@sanoma-ai/workflows 0.2.0 → 0.4.1

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 (149) hide show
  1. package/README.md +146 -85
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +14 -5
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/fill.d.ts +4 -0
  51. package/dist/fill.js +13 -0
  52. package/dist/fill.js.map +1 -1
  53. package/dist/index.d.ts +8 -4
  54. package/dist/index.js +10 -4
  55. package/dist/index.js.map +1 -1
  56. package/dist/ledger.d.ts +12 -1
  57. package/dist/ledger.js +18 -11
  58. package/dist/ledger.js.map +1 -1
  59. package/dist/lint.d.ts +5 -4
  60. package/dist/lint.js +24 -7
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +6 -0
  63. package/dist/op.js.map +1 -1
  64. package/dist/outline.d.ts +2 -0
  65. package/dist/outline.js +1 -1
  66. package/dist/outline.js.map +1 -1
  67. package/dist/paths.d.ts +4 -0
  68. package/dist/paths.js +17 -3
  69. package/dist/paths.js.map +1 -1
  70. package/dist/person.d.ts +61 -0
  71. package/dist/person.js +69 -0
  72. package/dist/person.js.map +1 -0
  73. package/dist/plugin.d.ts +28 -0
  74. package/dist/plugin.js +52 -1
  75. package/dist/plugin.js.map +1 -1
  76. package/dist/policy.d.ts +51 -55
  77. package/dist/policy.js +14 -36
  78. package/dist/policy.js.map +1 -1
  79. package/dist/resource.d.ts +21 -5
  80. package/dist/resource.js +12 -3
  81. package/dist/resource.js.map +1 -1
  82. package/dist/resources.d.ts +58 -6
  83. package/dist/resources.js +119 -26
  84. package/dist/resources.js.map +1 -1
  85. package/dist/run.d.ts +17 -1
  86. package/dist/run.js.map +1 -1
  87. package/dist/scenario.d.ts +16 -9
  88. package/dist/scenario.js +149 -44
  89. package/dist/scenario.js.map +1 -1
  90. package/dist/scenarios.d.ts +10 -6
  91. package/dist/scenarios.js +8 -4
  92. package/dist/scenarios.js.map +1 -1
  93. package/dist/schema.d.ts +32 -0
  94. package/dist/schema.js +225 -0
  95. package/dist/schema.js.map +1 -0
  96. package/dist/sdk.d.ts +34 -0
  97. package/dist/sdk.js +168 -0
  98. package/dist/sdk.js.map +1 -0
  99. package/dist/shared.d.ts +181 -5
  100. package/dist/shared.js +160 -7
  101. package/dist/shared.js.map +1 -1
  102. package/dist/sites.d.ts +52 -0
  103. package/dist/sites.js +174 -0
  104. package/dist/sites.js.map +1 -0
  105. package/dist/version.d.ts +3 -2
  106. package/dist/version.js +3 -2
  107. package/dist/version.js.map +1 -1
  108. package/dist/worker.d.ts +3 -2
  109. package/dist/worker.js +24 -4
  110. package/dist/worker.js.map +1 -1
  111. package/oxlint.json +7 -20
  112. package/package.json +8 -3
  113. package/src/approvals.ts +49 -13
  114. package/src/call-workflow.ts +44 -0
  115. package/src/call.ts +157 -45
  116. package/src/cedar.ts +493 -0
  117. package/src/client.ts +368 -37
  118. package/src/collection.ts +431 -0
  119. package/src/config.ts +131 -34
  120. package/src/constructors.ts +4 -0
  121. package/src/data.ts +10 -0
  122. package/src/datafile.ts +141 -21
  123. package/src/db.ts +90 -22
  124. package/src/define.ts +15 -10
  125. package/src/describe.ts +113 -10
  126. package/src/direct.ts +15 -0
  127. package/src/errors.ts +36 -1
  128. package/src/expectations.ts +17 -8
  129. package/src/fill.ts +15 -0
  130. package/src/index.ts +33 -2
  131. package/src/ledger.ts +35 -17
  132. package/src/lint.ts +29 -12
  133. package/src/op.ts +6 -0
  134. package/src/outline.ts +1 -1
  135. package/src/paths.ts +18 -3
  136. package/src/person.ts +75 -0
  137. package/src/plugin.ts +74 -5
  138. package/src/policy.ts +59 -63
  139. package/src/resource.ts +35 -8
  140. package/src/resources.ts +165 -24
  141. package/src/run.ts +18 -1
  142. package/src/scenario.ts +186 -57
  143. package/src/scenarios.ts +15 -8
  144. package/src/schema.ts +261 -0
  145. package/src/sdk.ts +211 -0
  146. package/src/shared.ts +271 -8
  147. package/src/sites.ts +220 -0
  148. package/src/version.ts +3 -2
  149. package/src/worker.ts +29 -6
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
+ import { isDirect } from "./direct.ts";
4
+ import type { Op } from "./op.ts";
5
+ import type { CALL_WORKFLOW } from "./shared.ts";
6
+
7
+ export { CALL_WORKFLOW } from "./shared.ts";
8
+
9
+ // The built-in `call` workflow: one operation that may be called directly (`isDirect`), as a run.
10
+ // `SanomaClient.call` starts it, as the person, when the policy holds a direct call for an
11
+ // approval, so the approval reaches the Inbox like any run's. It is a workflow like any other,
12
+ // held to the same lint: the call goes through `ctx`, under the policy, in the ledger.
13
+
14
+ /** The call workflow's input: the operation's id, and its input. */
15
+ export const CallInput = z.object({ op: z.string(), input: z.unknown() });
16
+
17
+ /**
18
+ * The call workflow for a config: it `uses` every operation that may be called directly and has
19
+ * a driver; `resolveConfig` adds it when there is one. A run calls `input.op` with `input.input`,
20
+ * and returns its output; inside it the call is made as the person who started the run, so a
21
+ * collection's save records them as `updatedBy`.
22
+ */
23
+ export function callWorkflow(
24
+ ops: ReadonlyMap<string, Op>,
25
+ drivers: ReadonlyMap<string, unknown>,
26
+ types: ReadonlyMap<string, unknown>,
27
+ ): WorkflowDefinition<readonly Op[], typeof CallInput> {
28
+ const uses = [...ops.values()]
29
+ .filter((op) => isDirect(op, types) && drivers.has(op.id))
30
+ .toSorted((a, b) => a.id.localeCompare(b.id));
31
+ return defineWorkflow({
32
+ // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
33
+ name: "call" satisfies typeof CALL_WORKFLOW,
34
+ title: "Call an operation",
35
+ trigger: "manual",
36
+ input: CallInput,
37
+ uses,
38
+ // `ctx` refuses an operation that is not in `uses`: one that may not be called directly, or has no driver.
39
+ run: async (ctx, input) => {
40
+ const [vendor = "", resource = "", name = ""] = input.op.split(".");
41
+ return ctx[vendor]![resource]![name]!(input.input);
42
+ },
43
+ });
44
+ }
package/src/call.ts CHANGED
@@ -3,7 +3,9 @@ import { awaitApproval, type CheckedApproval } from "./approvals.ts";
3
3
  import { offsetOf } from "./ast.ts";
4
4
  import {
5
5
  AllMembers,
6
+ type ApprovalState,
6
7
  type ApprovalRequest,
8
+ type Principal,
7
9
  ApprovalRequestSchema,
8
10
  SleepFor,
9
11
  type SleepRequest,
@@ -22,14 +24,24 @@ import {
22
24
  PolicyDeniedError,
23
25
  SanomaError,
24
26
  } from "./errors.ts";
27
+ import type { Collection } from "./collection.ts";
25
28
  import { currentGroup, entry, skipped, write, writeFailure } from "./ledger.ts";
26
29
  import { shown } from "./log.ts";
27
30
  import { type CallContext, isOp, type Op } from "./op.ts";
28
31
  import { callAt, callName, type CallNode } from "./outline.ts";
29
- import { DecisionSchema, type PolicyCall, policyOpOf, type RecordedDecision } from "./policy.ts";
30
- import type { Run, WorkerState } from "./run.ts";
32
+ import { allowAll, type Decision, type Policy, type PolicyCall, policyOpOf, type RecordedDecision } from "./policy.ts";
33
+ import type { Run, StartHold, WorkerState } from "./run.ts";
31
34
  import { isChangeOp } from "./resource.ts";
32
- import { APPLY_WORKFLOW, approverLabel, errorMessage, fitsOp } from "./shared.ts";
35
+ import {
36
+ APPLY_WORKFLOW,
37
+ approverLabel,
38
+ CALL_WORKFLOW,
39
+ DATA_VENDOR,
40
+ errorMessage,
41
+ fitsOp,
42
+ type Saved,
43
+ WORKFLOW_ACTION,
44
+ } from "./shared.ts";
33
45
 
34
46
  /** DBOS took the run from this execution: it cancelled the run, or another process took it over. */
35
47
  const takenByDbos = (err: unknown) =>
@@ -51,7 +63,7 @@ export function isInfrastructureError(err: unknown, state: Pick<WorkerState, "st
51
63
  }
52
64
 
53
65
  /** The value as the ledger records it: each of the operation's `opaque` fields as `"<name>"`. */
54
- function recorded(op: Op, value: unknown): unknown {
66
+ export function recorded(op: Op, value: unknown): unknown {
55
67
  if (!op.opaque?.length || typeof value !== "object" || value === null || Array.isArray(value)) return value;
56
68
  const out: Record<string, unknown> = { ...value };
57
69
  for (const name of op.opaque) if (out[name] !== undefined) out[name] = `<${name}>`;
@@ -252,16 +264,6 @@ function strict<T extends object>(obj: T, path: string, workflow: string): T {
252
264
  });
253
265
  }
254
266
 
255
- /** Checks a policy's answer, and copies it so nothing else the policy returned is recorded. */
256
- function checkDecision(decision: unknown, opId: string): RecordedDecision {
257
- const parsed = DecisionSchema.safeParse(decision);
258
- if (parsed.success) return parsed.data;
259
- const why = parsed.error.issues[0]?.message ?? "not a decision";
260
- throw new Error(
261
- `The policy returned ${shown(decision)} for ${opId}: ${why}; use allow(), deny(reason) or approve(approver)`,
262
- );
263
- }
264
-
265
267
  /** The resource instance the call acts on, from the operation's `target`, when it declares one. */
266
268
  function targetOf(op: Op, input: unknown): { target?: string } {
267
269
  if (!op.target) return {};
@@ -272,35 +274,138 @@ function targetOf(op: Op, input: unknown): { target?: string } {
272
274
  return { target };
273
275
  }
274
276
 
275
- /** Asks the policy about one call, and checks its answer. Runs inside a step, so it must have no side effects of its own. */
277
+ /**
278
+ * Asks the policy about one call, with the record a collection's `save` or
279
+ * `delete` replaces as `before` (`beforeOf`). Runs inside a step, so it must have no side effects
280
+ * of its own.
281
+ */
276
282
  export async function decide(run: Run, op: Op, input: unknown): Promise<RecordedDecision> {
277
- const call: PolicyCall = {
278
- op: policyOpOf(op),
279
- effect: op.effect,
280
- ...targetOf(op, input),
281
- input,
282
- actor: run.actor,
283
- run: { id: run.id, workflow: run.workflow, approvals: structuredClone(run.approvals) },
283
+ const seen = { id: run.id, workflow: run.workflow, approvals: structuredClone(run.approvals) };
284
+ const { policy } = run.state;
285
+ // Keyed by the call (`seq` was taken for it), so a fake answers each read afresh.
286
+ const before = await beforeOf(policy, op, input, run.state.collections, (get, key) =>
287
+ driverOf(run, get)?.(
288
+ { key },
289
+ { idempotencyKey: `${run.id}:${run.seq}:before`, runId: run.id, opId: get, attempt: 1, secrets: secretsOf(run) },
290
+ ),
291
+ );
292
+ const call = { ...callOf(op, input, run.actor, seen), ...before };
293
+ return ask(policy, op.id, () => policy(call), run.state);
294
+ }
295
+
296
+ /** The run's function for an operation: its own fake's in a sandbox run, never a vendor's; else the driver's. */
297
+ const driverOf = (run: Run, id: string) => (run.sandbox === undefined ? run.state.drivers : run.fakeDrivers)?.get(id);
298
+
299
+ /** The secrets a run's calls read: its own in a sandbox run, else the stored ones. */
300
+ const secretsOf = (run: Run) => (run.sandbox === undefined ? run.state.secrets : run.fakeSecrets!);
301
+
302
+ /** One call of `op` as `actor` in `run`, as a policy sees it. */
303
+ const callOf = (op: Op, input: unknown, actor: Principal, run: PolicyCall["run"]): PolicyCall => ({
304
+ op: policyOpOf(op),
305
+ effect: op.effect,
306
+ ...targetOf(op, input),
307
+ input,
308
+ actor,
309
+ run,
310
+ });
311
+
312
+ /**
313
+ * For a collection's `save` or `delete`, the record it replaces or removes, read once with the
314
+ * collection's `get` (`read`): `{ before }` when there is one, else nothing, and nothing read
315
+ * under `allowAll`, which reads no call. A failed read is named, keeping its error's code.
316
+ */
317
+ export async function beforeOf(
318
+ policy: Policy,
319
+ op: Op,
320
+ input: unknown,
321
+ collections: ReadonlyMap<string, Collection>,
322
+ read: (get: string, key: unknown) => Promise<unknown> | undefined,
323
+ ): Promise<{ before?: unknown }> {
324
+ const collection = op.vendor === DATA_VENDOR ? collections.get(op.resource) : undefined;
325
+ if (policy === allowAll || !collection || (op.name !== "save" && op.name !== "delete")) return {};
326
+ const key =
327
+ op.name === "save"
328
+ ? (input as { record: Record<string, unknown> }).record[collection.key]
329
+ : (input as { key: unknown }).key;
330
+ let saved: Saved | null | undefined;
331
+ try {
332
+ saved = (await read(`${DATA_VENDOR}.${op.resource}.get`, key)) as Saved | null | undefined;
333
+ } catch (err) {
334
+ if (takenByDbos(err)) throw err;
335
+ throw keepCode(new Error(`Reading the record ${op.id} replaces failed: ${errorMessage(err)}`, { cause: err }), err);
336
+ }
337
+ return saved ? { before: saved.record } : {};
338
+ }
339
+
340
+ /**
341
+ * Asks `policy` about one call of `op` as `actor` in `run`: a direct call
342
+ * (`SanomaClient.call`), with the record a collection's write replaces as `before` (`beforeOf`).
343
+ */
344
+ export function decideCall(
345
+ policy: Policy,
346
+ op: Op,
347
+ input: unknown,
348
+ actor: Principal,
349
+ run: PolicyCall["run"],
350
+ before: Pick<PolicyCall, "before"> = {},
351
+ ): RecordedDecision {
352
+ const call = { ...callOf(op, input, actor, run), ...before };
353
+ return ask(policy, op.id, () => policy(call));
354
+ }
355
+
356
+ /**
357
+ * Asks the policy whether `actor` may start a run of `workflow` with `input` (`Policy.start`, the
358
+ * action `workflow:<name>`), as `SanomaClient.start` does before it queues one.
359
+ */
360
+ export function decideStart(
361
+ policy: Policy,
362
+ workflow: string,
363
+ input: unknown,
364
+ actor: Principal,
365
+ approvals: readonly ApprovalState[] = [],
366
+ ) {
367
+ return ask(policy, `${WORKFLOW_ACTION}${workflow}`, () => policy.start(actor, workflow, input, approvals));
368
+ }
369
+
370
+ /** The approval a start's `approve` answer waits on: whom it asks, its title, and the rules that held it. */
371
+ export function startHoldOf(
372
+ workflow: string,
373
+ { approver, title, reasons }: Extract<Decision, { kind: "approve" }>,
374
+ ): StartHold {
375
+ return {
376
+ approver,
377
+ title: title ?? `${WORKFLOW_ACTION}${workflow} needs ${approverLabel(approver)}`,
378
+ ...(reasons && { reasons }),
284
379
  };
285
- let answer: unknown;
380
+ }
381
+
382
+ /**
383
+ * The policy's answer about `id` (`answer`), with its version. A policy that fails is named in
384
+ * the error, with its error's code, unless the worker is stopping (`state`).
385
+ */
386
+ function ask(
387
+ policy: Policy,
388
+ id: string,
389
+ answer: () => Decision,
390
+ state: Pick<WorkerState, "stopped"> = { stopped: false },
391
+ ): RecordedDecision {
392
+ let decision: Decision;
286
393
  try {
287
- answer = await run.state.policy(call);
394
+ decision = answer();
288
395
  } catch (err) {
289
396
  // Named here, so the run's error and the ledger say which call the policy failed on, with
290
397
  // the code the policy's error had.
291
- if (isInfrastructureError(err, run.state)) throw err;
292
- throw keepCode(new Error(`The policy failed deciding ${op.id}: ${errorMessage(err)}`, { cause: err }), err);
398
+ if (isInfrastructureError(err, state)) throw err;
399
+ throw keepCode(new Error(`The policy failed deciding ${id}: ${errorMessage(err)}`, { cause: err }), err);
293
400
  }
294
- const decision = checkDecision(answer, op.id);
295
- const policyVersion = run.state.policy.version;
296
- return policyVersion === undefined ? decision : { ...decision, policyVersion };
401
+ return policy.version === undefined ? decision : { ...decision, policyVersion: policy.version };
297
402
  }
298
403
 
299
404
  async function callOp(run: Run, id: string, input: unknown, place: Placed) {
300
405
  // The worker's declaration, never the workflow's: its effect, schemas and retry setting.
301
406
  const op = run.state.ops.get(id);
302
407
  // A sandbox run calls its own fakes, never a vendor.
303
- const fn = run.sandbox === undefined ? run.state.drivers.get(id) : run.fakeDrivers?.get(id);
408
+ const fn = driverOf(run, id);
304
409
  if (!op || !fn) {
305
410
  throw new Error(
306
411
  run.sandbox === undefined
@@ -324,24 +429,27 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
324
429
  // The decision is recorded as a step, so a replay reuses it instead of asking a policy
325
430
  // that may have changed since. The policy must still be deterministic: a run that fails
326
431
  // before the step is recorded asks again.
327
- const decision = await DBOS.runStep(() => decide(run, op, parsed), { name: `policy:${op.id}` });
432
+ const decideStep = () => DBOS.runStep(() => decide(run, op, parsed), { name: `policy:${op.id}` });
433
+ let decision = await decideStep();
328
434
  const logged = recorded(op, parsed);
329
435
  const call = { type: "op.called", op: op.id, ...place, effect: op.effect, input: logged, decision } as const;
330
436
 
331
- if (decision.kind === "deny") {
332
- const err = new PolicyDeniedError(op.id, decision.reason);
333
- await writeFailure(run, entry(run, { ...call, error: errorInfo(err), durationMs: 0 }, { seq }), err);
334
- throw err;
335
- }
336
- if (decision.kind === "approve") {
437
+ // Each hold is lifted in turn: once one is approved, the policy is asked again, with it.
438
+ for (;;) {
439
+ if (decision.kind === "deny") {
440
+ const err = new PolicyDeniedError(op.id, decision.reason);
441
+ await writeFailure(run, entry(run, { ...call, decision, error: errorInfo(err), durationMs: 0 }, { seq }), err);
442
+ throw err;
443
+ }
444
+ if (decision.kind !== "approve") break;
337
445
  const title = decision.title ?? `${op.id} needs ${approverLabel(decision.approver)}`;
338
- // A hold covers the call it held, and any other operations the policy named.
339
- const covers = [...new Set([op.id, ...(decision.covers ?? [])])];
340
446
  try {
341
- await awaitApproval(run, title, { approver: decision.approver, covers }, place, {
447
+ // A hold covers the call it held.
448
+ await awaitApproval(run, title, { approver: decision.approver, covers: [op.id] }, place, {
342
449
  op: op.id,
343
450
  seq,
344
451
  input: logged,
452
+ reasons: decision.reasons,
345
453
  });
346
454
  } catch (err) {
347
455
  if (errorCode(err) === "approval_rejected") {
@@ -351,6 +459,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
351
459
  }
352
460
  throw err;
353
461
  }
462
+ decision = await decideStep();
354
463
  }
355
464
 
356
465
  const started = Date.now();
@@ -363,13 +472,15 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
363
472
  async () => {
364
473
  const at = Date.now();
365
474
  attempt = DBOS.stepStatus?.currentAttempt ?? 1;
366
- const secrets = run.sandbox === undefined ? run.state.secrets : run.fakeSecrets!;
475
+ const secrets = secretsOf(run);
367
476
  const context: CallContext = {
368
477
  idempotencyKey: `${run.id}:${seq}`,
369
478
  runId: run.id,
370
479
  opId: op.id,
371
480
  attempt,
372
481
  secrets,
482
+ // A call run is the person's direct call held for an approval: made as them, as it would have been.
483
+ by: run.workflow === CALL_WORKFLOW ? run.actor.id : `run:${run.id}`,
373
484
  };
374
485
  // A live driver's `env` is checked here, inside the step, by the guard `indexDrivers` put
375
486
  // on its function: the refusal is the call's failure, recorded and never retried.
@@ -456,13 +567,14 @@ async function all(run: Run, list: unknown, node: string): Promise<unknown[]> {
456
567
 
457
568
  /**
458
569
  * Waits, durably, until the time or for the duration the request names, and records when it
459
- * ends. For a duration that is the time the sleep started plus the duration, as the runtime saw
460
- * it (`Date.now()`, which adds no DBOS call): a replay may compute another, but the store keeps
570
+ * ends. For a duration that is the record's `at` plus the duration, one read of the runtime's
571
+ * clock (`Date.now()`, which adds no DBOS call): a replay may compute another, but the store keeps
461
572
  * the first record with the id, so the record says what the first execution waited for.
462
573
  */
463
574
  async function sleep(run: Run, raw: unknown, at: Placed) {
464
575
  const req = checkSleep(raw);
465
576
  const seq = run.seq++;
577
+ const started = Date.now();
466
578
  let until: number;
467
579
  let ms: number;
468
580
  if ("until" in req) {
@@ -475,9 +587,9 @@ async function sleep(run: Run, raw: unknown, at: Placed) {
475
587
  (req.minutes ?? 0) * 60_000 +
476
588
  (req.hours ?? 0) * 3_600_000 +
477
589
  (req.days ?? 0) * 86_400_000;
478
- until = Date.now() + ms;
590
+ until = started + ms;
479
591
  }
480
- await write(run, entry(run, { type: "sleep.started", ...at, until }, { seq }));
592
+ await write(run, entry(run, { type: "sleep.started", ...at, until }, { seq, at: started }));
481
593
  // A time already past waits not at all, and adds no step. A sandbox run never waits.
482
594
  if (ms > 0 && run.sandbox === undefined) await DBOS.sleep(ms);
483
595
  }