@swirls/looms 0.4.0 → 0.5.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 (243) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/actor/cell.d.ts +21 -2
  3. package/dist/actor/cell.js +15 -3
  4. package/dist/actor/host.d.ts +10 -2
  5. package/dist/actor/host.js +27 -4
  6. package/dist/actor/index.d.ts +2 -1
  7. package/dist/actor/index.js +1 -1
  8. package/dist/actor/routing.d.ts +3 -0
  9. package/dist/actor/routing.js +13 -2
  10. package/dist/agent/definitions-store.d.ts +1 -1
  11. package/dist/agent/definitions-store.js +1 -1
  12. package/dist/agent/definitions.d.ts +48 -0
  13. package/dist/agent/definitions.js +44 -0
  14. package/dist/agent/effects.d.ts +184 -0
  15. package/dist/agent/effects.js +172 -40
  16. package/dist/agent/events.d.ts +89 -0
  17. package/dist/agent/events.js +25 -0
  18. package/dist/agent/module.d.ts +412 -0
  19. package/dist/agent/module.js +5 -1
  20. package/dist/agent/scope.d.ts +88 -0
  21. package/dist/agent/threads.js +39 -3
  22. package/dist/agent/types.d.ts +4 -0
  23. package/dist/approval/effects.d.ts +40 -0
  24. package/dist/approval/module.d.ts +80 -0
  25. package/dist/client/client.d.ts +468 -119
  26. package/dist/client/client.js +214 -21
  27. package/dist/client/index.d.ts +1 -1
  28. package/dist/client/sse.d.ts +1 -1
  29. package/dist/client/sse.js +2 -2
  30. package/dist/cloudflare/durable-object.d.ts +66 -2
  31. package/dist/cloudflare/durable-object.js +96 -1
  32. package/dist/cloudflare/event-store.d.ts +7 -3
  33. package/dist/cloudflare/event-store.js +16 -3
  34. package/dist/cloudflare/index.d.ts +7 -1
  35. package/dist/cloudflare/index.js +7 -1
  36. package/dist/cloudflare/memory-queue.d.ts +18 -0
  37. package/dist/cloudflare/memory-queue.js +53 -0
  38. package/dist/cloudflare/queue-consumer.d.ts +52 -0
  39. package/dist/cloudflare/queue-consumer.js +102 -0
  40. package/dist/cloudflare/queue-message.d.ts +31 -0
  41. package/dist/cloudflare/queue-message.js +33 -0
  42. package/dist/cloudflare/queue-pull.d.ts +62 -0
  43. package/dist/cloudflare/queue-pull.js +130 -0
  44. package/dist/cloudflare/queue-worker.d.ts +39 -0
  45. package/dist/cloudflare/queue-worker.js +62 -0
  46. package/dist/cloudflare/queues.d.ts +10 -0
  47. package/dist/cloudflare/queues.js +10 -0
  48. package/dist/cloudflare/r2-blob-store.d.ts +33 -0
  49. package/dist/cloudflare/r2-blob-store.js +60 -0
  50. package/dist/cloudflare/router.d.ts +4 -1
  51. package/dist/cloudflare/router.js +8 -3
  52. package/dist/cloudflare/websocket.d.ts +27 -0
  53. package/dist/cloudflare/websocket.js +215 -0
  54. package/dist/core/blob-offload.d.ts +21 -0
  55. package/dist/core/blob-offload.js +143 -0
  56. package/dist/core/blob-store.d.ts +45 -0
  57. package/dist/core/blob-store.js +164 -0
  58. package/dist/core/blobs.d.ts +37 -0
  59. package/dist/core/blobs.js +160 -0
  60. package/dist/core/definition-resolver.d.ts +40 -0
  61. package/dist/core/definition-resolver.js +129 -0
  62. package/dist/core/definition-store.d.ts +6 -0
  63. package/dist/core/definition-store.js +37 -1
  64. package/dist/core/effects.d.ts +86 -3
  65. package/dist/core/effects.js +99 -3
  66. package/dist/core/envelope.d.ts +9 -0
  67. package/dist/core/envelope.js +4 -0
  68. package/dist/core/event-codec.js +3 -0
  69. package/dist/core/fold.js +83 -1
  70. package/dist/core/ids.d.ts +2 -0
  71. package/dist/core/ids.js +4 -0
  72. package/dist/core/index.d.ts +8 -0
  73. package/dist/core/index.js +8 -0
  74. package/dist/core/launcher.d.ts +97 -0
  75. package/dist/core/launcher.js +67 -0
  76. package/dist/core/module-scope.d.ts +7 -3
  77. package/dist/core/module-scope.js +3 -0
  78. package/dist/core/module.d.ts +10 -0
  79. package/dist/core/projection.js +4 -0
  80. package/dist/core/protocol.d.ts +36 -1
  81. package/dist/core/protocol.js +35 -0
  82. package/dist/core/published.d.ts +2 -0
  83. package/dist/core/published.js +4 -1
  84. package/dist/core/replay.d.ts +96 -0
  85. package/dist/core/run-context.d.ts +33 -0
  86. package/dist/core/run-context.js +23 -0
  87. package/dist/core/sqlite-store.js +57 -5
  88. package/dist/core/state.d.ts +60 -0
  89. package/dist/core/state.js +19 -0
  90. package/dist/core/store.d.ts +6 -0
  91. package/dist/core/store.js +7 -0
  92. package/dist/core/subscription.d.ts +97 -0
  93. package/dist/core/subscription.js +224 -0
  94. package/dist/core/thread.d.ts +7 -0
  95. package/dist/core/thread.js +1 -0
  96. package/dist/core/trace.d.ts +21 -0
  97. package/dist/core/trace.js +45 -0
  98. package/dist/core/worker-callback.d.ts +25 -1
  99. package/dist/debugger/app/assets/{index-CgDzWJg7.css → index-B7GdT1UA.css} +1 -1
  100. package/dist/debugger/app/assets/index-C31SnCuN.js +22 -0
  101. package/dist/debugger/app/assets/routes-DPA3O5AF.js +47 -0
  102. package/dist/debugger/app/index.html +2 -2
  103. package/dist/debugger/components/event-inspector.d.ts +3 -1
  104. package/dist/debugger/components/event-inspector.js +25 -4
  105. package/dist/debugger/components/event-stream.d.ts +4 -1
  106. package/dist/debugger/components/event-stream.js +2 -2
  107. package/dist/debugger/components/run-tree.d.ts +5 -2
  108. package/dist/debugger/components/run-tree.js +10 -4
  109. package/dist/debugger/context.d.ts +8 -1
  110. package/dist/debugger/context.js +15 -3
  111. package/dist/debugger/events/protocol-summarize.js +18 -3
  112. package/dist/debugger/index.d.ts +1 -1
  113. package/dist/debugger/index.js +1 -1
  114. package/dist/debugger/shell/debugger-shell.js +1 -1
  115. package/dist/debugger/shell/events-pane.js +4 -3
  116. package/dist/debugger/styles.css +1 -0
  117. package/dist/evaluator/definitions-store.d.ts +3 -4
  118. package/dist/evaluator/definitions-store.js +3 -3
  119. package/dist/evaluator/definitions.d.ts +4 -1
  120. package/dist/evaluator/definitions.js +5 -2
  121. package/dist/evaluator/effects.d.ts +41 -0
  122. package/dist/evaluator/effects.js +6 -2
  123. package/dist/evaluator/module.d.ts +84 -0
  124. package/dist/evaluator/module.js +7 -3
  125. package/dist/evaluator/threads.d.ts +2 -0
  126. package/dist/evaluator/threads.js +4 -0
  127. package/dist/projectors/event-log.d.ts +87 -0
  128. package/dist/projectors/event-log.js +396 -0
  129. package/dist/projectors/index-model.d.ts +12 -0
  130. package/dist/projectors/index-model.js +12 -3
  131. package/dist/projectors/memory.js +7 -0
  132. package/dist/projectors/postgres.d.ts +1 -0
  133. package/dist/projectors/postgres.js +32 -15
  134. package/dist/projectors/schema.d.ts +5 -0
  135. package/dist/projectors/schema.js +26 -1
  136. package/dist/projectors/sqlite.js +47 -19
  137. package/dist/projectors/with-projectors.d.ts +7 -1
  138. package/dist/projectors/with-projectors.js +13 -3
  139. package/dist/react/materialize.js +5 -1
  140. package/dist/react/react.d.ts +10 -2
  141. package/dist/react/react.js +43 -19
  142. package/dist/react/store.d.ts +11 -2
  143. package/dist/react/store.js +104 -12
  144. package/dist/react/tables.d.ts +4 -1
  145. package/dist/runtime/cancellation.d.ts +2 -1
  146. package/dist/runtime/cancellation.js +35 -2
  147. package/dist/runtime/child-run.d.ts +10 -0
  148. package/dist/runtime/child-run.js +58 -0
  149. package/dist/runtime/deadline-transition-planner.js +11 -2
  150. package/dist/runtime/dispatch-effect.d.ts +2 -2
  151. package/dist/runtime/dispatch-effect.js +43 -19
  152. package/dist/runtime/errors.d.ts +14 -1
  153. package/dist/runtime/errors.js +10 -0
  154. package/dist/runtime/filesystem-blob-store.d.ts +6 -0
  155. package/dist/runtime/filesystem-blob-store.js +59 -0
  156. package/dist/runtime/helpers.d.ts +15 -6
  157. package/dist/runtime/helpers.js +46 -6
  158. package/dist/runtime/index.d.ts +4 -2
  159. package/dist/runtime/index.js +3 -1
  160. package/dist/runtime/local-effect-executor.d.ts +11 -2
  161. package/dist/runtime/local-effect-executor.js +80 -4
  162. package/dist/runtime/looms.d.ts +38 -4
  163. package/dist/runtime/looms.js +17 -3
  164. package/dist/runtime/repository.d.ts +5 -4
  165. package/dist/runtime/repository.js +5 -2
  166. package/dist/runtime/retry-policy.d.ts +5 -0
  167. package/dist/runtime/retry-policy.js +10 -2
  168. package/dist/runtime/run-start.d.ts +1 -1
  169. package/dist/runtime/run-start.js +10 -0
  170. package/dist/runtime/runtime.d.ts +2 -2
  171. package/dist/runtime/runtime.js +191 -68
  172. package/dist/runtime/server/auth.d.ts +3 -0
  173. package/dist/runtime/server/fetch-handler.d.ts +2 -2
  174. package/dist/runtime/server/fetch-handler.js +35 -4
  175. package/dist/runtime/server/grant.d.ts +49 -0
  176. package/dist/runtime/server/grant.js +177 -0
  177. package/dist/runtime/server/routes/effects.js +47 -5
  178. package/dist/runtime/server/routes/runs.js +6 -1
  179. package/dist/runtime/server/run-route.d.ts +18 -0
  180. package/dist/runtime/server/run-route.js +26 -0
  181. package/dist/runtime/server.d.ts +3 -1
  182. package/dist/runtime/server.js +3 -1
  183. package/dist/runtime/sse.js +26 -4
  184. package/dist/runtime/subscription.d.ts +8 -0
  185. package/dist/runtime/subscription.js +23 -0
  186. package/dist/runtime/types.d.ts +87 -8
  187. package/dist/runtime/wake-engine.d.ts +4 -2
  188. package/dist/runtime/wake-engine.js +57 -6
  189. package/dist/runtime/worker-callbacks.d.ts +12 -2
  190. package/dist/runtime/worker-callbacks.js +102 -72
  191. package/dist/runtime/worker-effect-dispatch.d.ts +14 -5
  192. package/dist/runtime/worker-effect-dispatch.js +84 -18
  193. package/dist/schedule/debugger/index.d.ts +4 -0
  194. package/dist/schedule/debugger/index.js +11 -0
  195. package/dist/schedule/debugger/schedule-view.d.ts +3 -0
  196. package/dist/schedule/debugger/schedule-view.js +68 -0
  197. package/dist/schedule/debugger/summarize.d.ts +2 -0
  198. package/dist/schedule/debugger/summarize.js +43 -0
  199. package/dist/schedule/definitions-store.d.ts +10 -0
  200. package/dist/schedule/definitions-store.js +8 -0
  201. package/dist/schedule/definitions.d.ts +57 -0
  202. package/dist/schedule/definitions.js +30 -0
  203. package/dist/schedule/effects.d.ts +756 -0
  204. package/dist/schedule/effects.js +244 -0
  205. package/dist/schedule/ensure.d.ts +55 -0
  206. package/dist/schedule/ensure.js +109 -0
  207. package/dist/schedule/events.d.ts +116 -0
  208. package/dist/schedule/events.js +49 -0
  209. package/dist/schedule/index.d.ts +11 -0
  210. package/dist/schedule/index.js +11 -0
  211. package/dist/schedule/module.d.ts +1474 -0
  212. package/dist/schedule/module.js +22 -0
  213. package/dist/schedule/projections.d.ts +55 -0
  214. package/dist/schedule/projections.js +88 -0
  215. package/dist/schedule/scope.d.ts +121 -0
  216. package/dist/schedule/scope.js +7 -0
  217. package/dist/schedule/signals.d.ts +25 -0
  218. package/dist/schedule/signals.js +33 -0
  219. package/dist/schedule/specs.d.ts +59 -0
  220. package/dist/schedule/specs.js +231 -0
  221. package/dist/schedule/threads.d.ts +25 -0
  222. package/dist/schedule/threads.js +259 -0
  223. package/dist/testing/corpus-module.d.ts +34 -0
  224. package/dist/testing/corpus-module.js +87 -0
  225. package/dist/testing/index.d.ts +2 -0
  226. package/dist/testing/index.js +5 -6
  227. package/dist/testing/replay-corpus.d.ts +22 -0
  228. package/dist/testing/replay-corpus.js +50 -0
  229. package/dist/workflow/definitions-store.d.ts +1 -1
  230. package/dist/workflow/definitions-store.js +1 -1
  231. package/dist/workflow/definitions.d.ts +14 -0
  232. package/dist/workflow/effects.d.ts +122 -0
  233. package/dist/workflow/effects.js +16 -6
  234. package/dist/workflow/events.d.ts +42 -0
  235. package/dist/workflow/events.js +4 -0
  236. package/dist/workflow/module.d.ts +313 -0
  237. package/dist/workflow/module.js +7 -3
  238. package/dist/workflow/scope.d.ts +42 -0
  239. package/dist/workflow/threads.d.ts +72 -0
  240. package/dist/workflow/threads.js +106 -38
  241. package/package.json +14 -1
  242. package/dist/debugger/app/assets/index-D--9esm9.js +0 -22
  243. package/dist/debugger/app/assets/routes-Dakrdusf.js +0 -47
@@ -20,7 +20,19 @@ function workerStartedEvent(runId, item, attempt, retry, now) {
20
20
  origin: WORKER_ORIGIN,
21
21
  });
22
22
  }
23
- const IGNORED = { events: [], notices: [] };
23
+ const STALE = {
24
+ events: [],
25
+ notices: [],
26
+ result: { status: 'stale', cancelRequested: false },
27
+ };
28
+ const CANCELLED = {
29
+ events: [],
30
+ notices: [],
31
+ result: { status: 'cancelled', cancelRequested: true },
32
+ };
33
+ function accepted(events, notices = [], cancelRequested = false) {
34
+ return { events, notices, result: { status: 'accepted', cancelRequested } };
35
+ }
24
36
  /**
25
37
  * Turn a worker callback into events against the current durable state. Callbacks for another
26
38
  * attempt, a settled attempt, or an effect running in process are ignored.
@@ -32,6 +44,12 @@ function planWorkerCallback(input) {
32
44
  const isCancellationAcknowledgement = callback.kind === 'cancelled' &&
33
45
  execution?.attempt === callback.attempt &&
34
46
  execution.status === 'cancel_requested';
47
+ // A message already handed to a worker cannot be pulled back. Tell it to stop instead.
48
+ if (execution?.attempt === callback.attempt &&
49
+ execution.status === 'cancel_requested' &&
50
+ callback.kind !== 'cancelled') {
51
+ return Effect.succeed(CANCELLED);
52
+ }
35
53
  if (!execution ||
36
54
  execution.attempt !== callback.attempt ||
37
55
  (isRunTerminal(state) && !isCancellationAcknowledgement) ||
@@ -40,9 +58,8 @@ function planWorkerCallback(input) {
40
58
  execution.status === 'retry_wait' ||
41
59
  execution.status === 'timed_out' ||
42
60
  execution.status === 'ambiguous' ||
43
- (callback.kind === 'cancelled' && !isCancellationAcknowledgement) ||
44
- (execution.status === 'cancel_requested' && callback.kind !== 'cancelled')) {
45
- return Effect.succeed(IGNORED);
61
+ (callback.kind === 'cancelled' && !isCancellationAcknowledgement)) {
62
+ return Effect.succeed(STALE);
46
63
  }
47
64
  const retry = item ? registry.effects.get(item.effect.type)?.retry : undefined;
48
65
  const notice = (type) => ({
@@ -55,55 +72,53 @@ function planWorkerCallback(input) {
55
72
  switch (callback.kind) {
56
73
  case 'started': {
57
74
  return Effect.succeed(item && execution.status === 'dispatched'
58
- ? { events: [workerStartedEvent(runId, item, callback.attempt, retry, now)], notices: [] }
59
- : IGNORED);
75
+ ? accepted([workerStartedEvent(runId, item, callback.attempt, retry, now)])
76
+ : STALE);
60
77
  }
61
78
  case 'heartbeat': {
62
79
  const implicitStart = execution.status === 'dispatched';
63
80
  if (!item ||
64
81
  (!implicitStart && execution.status !== 'started' && execution.status !== 'heartbeat')) {
65
- return Effect.succeed(IGNORED);
82
+ return Effect.succeed(STALE);
66
83
  }
67
- return Effect.succeed({
68
- events: [
69
- ...(implicitStart ? [workerStartedEvent(runId, item, callback.attempt, retry, now)] : []),
70
- createEvent(runId, {
71
- type: 'runtime.effect.heartbeat',
72
- payload: {
73
- effectId: item.effectId,
74
- attempt: callback.attempt,
75
- heartbeatAt: now,
76
- deadlineAt: workerDeadline(retry, execution.startedAt ?? now, now),
77
- },
78
- threadId: item.threadId,
79
- origin: WORKER_ORIGIN,
80
- }),
81
- ],
82
- notices: [notice('effect.heartbeat')],
83
- });
84
+ const recorded = {
85
+ effectId: item.effectId,
86
+ attempt: callback.attempt,
87
+ heartbeatAt: now,
88
+ deadlineAt: workerDeadline(retry, execution.startedAt ?? now, now),
89
+ };
90
+ const payload = callback.details === undefined ? recorded : { ...recorded, details: callback.details };
91
+ return Effect.succeed(accepted([
92
+ ...(implicitStart
93
+ ? [workerStartedEvent(runId, item, callback.attempt, retry, now)]
94
+ : []),
95
+ createEvent(runId, {
96
+ type: 'runtime.effect.heartbeat',
97
+ payload,
98
+ threadId: item.threadId,
99
+ origin: WORKER_ORIGIN,
100
+ }),
101
+ ], [notice('effect.heartbeat')]));
84
102
  }
85
103
  case 'complete': {
86
104
  if (!item) {
87
- return Effect.succeed(IGNORED);
105
+ return Effect.succeed(STALE);
88
106
  }
89
- return Effect.forEach(callback.events, (event) => materializeEffectOutcome(registry.catalogs, runId, item, event)).pipe(Effect.map((outcomes) => ({
90
- events: [
91
- ...outcomes,
92
- createEvent(runId, {
93
- type: 'runtime.effect.completed',
94
- payload: { effectId: item.effectId, attempt: callback.attempt },
95
- threadId: item.threadId,
96
- effectId: item.effectId,
97
- causationId: item.causingEventId,
98
- origin: WORKER_ORIGIN,
99
- }),
100
- ],
101
- notices: [],
102
- })));
107
+ return Effect.forEach(callback.events, (event) => materializeEffectOutcome(registry.catalogs, runId, item, event)).pipe(Effect.map((outcomes) => accepted([
108
+ ...outcomes,
109
+ createEvent(runId, {
110
+ type: 'runtime.effect.completed',
111
+ payload: { effectId: item.effectId, attempt: callback.attempt },
112
+ threadId: item.threadId,
113
+ effectId: item.effectId,
114
+ causationId: item.causingEventId,
115
+ origin: WORKER_ORIGIN,
116
+ }),
117
+ ])));
103
118
  }
104
119
  case 'fail': {
105
120
  if (!item) {
106
- return Effect.succeed(IGNORED);
121
+ return Effect.succeed(STALE);
107
122
  }
108
123
  const failure = planAttemptFailure({
109
124
  runId,
@@ -113,36 +128,32 @@ function planWorkerCallback(input) {
113
128
  now,
114
129
  retry,
115
130
  origin: WORKER_ORIGIN,
131
+ nonRetryable: callback.nonRetryable,
132
+ retryAfterMs: callback.retryAfterMs,
116
133
  });
117
- return Effect.succeed({
118
- events: [failure.event],
119
- notices: failure.retrying
120
- ? [
121
- {
122
- type: 'effect.retry',
123
- runId,
124
- at: now,
125
- effectId: item.effectId,
126
- attempt: callback.attempt,
127
- detail: callback.error,
128
- },
129
- ]
130
- : [],
131
- });
134
+ return Effect.succeed(accepted([failure.event], failure.retrying
135
+ ? [
136
+ {
137
+ type: 'effect.retry',
138
+ runId,
139
+ at: now,
140
+ effectId: item.effectId,
141
+ attempt: callback.attempt,
142
+ detail: callback.error,
143
+ },
144
+ ]
145
+ : []));
132
146
  }
133
147
  case 'cancelled': {
134
- return Effect.succeed({
135
- events: [
136
- createEvent(runId, {
137
- type: 'runtime.effect.cancelled',
138
- payload: { effectId: callback.effectId, attempt: callback.attempt },
139
- threadId: item?.threadId ?? null,
140
- effectId: callback.effectId,
141
- origin: WORKER_ORIGIN,
142
- }),
143
- ],
144
- notices: [notice('effect.cancellation')],
145
- });
148
+ return Effect.succeed(accepted([
149
+ createEvent(runId, {
150
+ type: 'runtime.effect.cancelled',
151
+ payload: { effectId: callback.effectId, attempt: callback.attempt },
152
+ threadId: item?.threadId ?? null,
153
+ effectId: callback.effectId,
154
+ origin: WORKER_ORIGIN,
155
+ }),
156
+ ], [notice('effect.cancellation')], true));
146
157
  }
147
158
  default: {
148
159
  const exhaustiveCheck = callback;
@@ -155,13 +166,32 @@ export function createWorkerCallbacks(options) {
155
166
  const applyWorkerCallback = (runId, callback) => withRunIngress(runId, Effect.gen(function* () {
156
167
  const store = yield* EventStoreTag;
157
168
  const now = yield* Clock.currentTimeMillis;
158
- const committed = yield* repository.commit(store, runId, (cursor) => planWorkerCallback({ runId, state: cursor.state, callback, registry, now }).pipe(Effect.map((plan) => ({ events: plan.events, value: plan.notices }))));
169
+ const committed = yield* repository.commit(store, runId, (cursor) => planWorkerCallback({ runId, state: cursor.state, callback, registry, now }).pipe(Effect.map((plan) => ({
170
+ events: plan.events,
171
+ value: { notices: plan.notices, result: plan.result },
172
+ }))));
159
173
  if (committed.append) {
160
- for (const notice of committed.value) {
174
+ for (const notice of committed.value.notices) {
161
175
  notifyObserver(options.observer, notice);
162
176
  }
163
177
  }
164
- return committed.cursor.state;
165
- })).pipe(Effect.andThen(options.wake(runId)));
166
- return { workerCallback: applyWorkerCallback };
178
+ return committed.value.result;
179
+ })).pipe(Effect.tap((result) => (result.status === 'stale' ? Effect.void : options.wake(runId))));
180
+ const workerSecrets = (runId, callback) => Effect.gen(function* () {
181
+ const store = yield* EventStoreTag;
182
+ const { state } = yield* repository.loadCursor(store, runId);
183
+ const execution = state.effectExecutions[callback.effectId];
184
+ if (execution?.attempt !== callback.attempt || isRunTerminal(state)) {
185
+ return { status: 'stale' };
186
+ }
187
+ if (execution.status === 'cancel_requested') {
188
+ return { status: 'cancelled' };
189
+ }
190
+ if (execution.status !== 'started' && execution.status !== 'heartbeat') {
191
+ return { status: 'stale' };
192
+ }
193
+ const secrets = store.runSecrets ? yield* store.runSecrets.get(runId) : {};
194
+ return { status: 'accepted', secrets };
195
+ });
196
+ return { workerCallback: applyWorkerCallback, workerSecrets };
167
197
  }
@@ -1,11 +1,12 @@
1
1
  import { Effect } from 'effect';
2
- import { type ComposedRegistry, type EffectExecutionRecord, type EventEnvelope, type OutstandingEffect, type RunState } from '@swirls/looms/core';
2
+ import { type ComposedRegistry, type EffectExecutionRecord, type EventEnvelope, type OutstandingEffect, type RetryPolicy, type RunState, type SecretContext } from '@swirls/looms/core';
3
3
  import type { EffectWorker, EffectWorkerTask } from './types';
4
4
  export interface WorkerEffectDispatchPlan {
5
5
  readonly item: OutstandingEffect;
6
6
  readonly attempt: number;
7
7
  readonly queuedEvent?: EventEnvelope;
8
8
  readonly task: EffectWorkerTask;
9
+ readonly retry: RetryPolicy | undefined;
9
10
  readonly scheduleToStartDeadlineAt: number | null;
10
11
  }
11
12
  /**
@@ -27,10 +28,18 @@ export declare function planWorkerEffects(input: {
27
28
  readonly registry: ComposedRegistry;
28
29
  readonly worker?: EffectWorker;
29
30
  readonly now: number;
31
+ readonly maxInFlightWorkerEffects?: number;
32
+ readonly secrets?: SecretContext;
30
33
  }): ReadonlyArray<WorkerEffectDispatchPlan>;
31
34
  /**
32
- * Hand a planned attempt to the worker. A rejected dispatch may still have reached the worker, so
33
- * with a schedule-to-start deadline it is recorded as dispatched and recovered by that timeout;
34
- * without one it is parked as ambiguous for an operator.
35
+ * Hand a planned attempt to the worker. When an unacknowledged dispatch rejects, the task may still
36
+ * have reached the worker, so with a schedule-to-start deadline it is recorded as dispatched and
37
+ * recovered by that timeout; without one it is parked as ambiguous for an operator. An acknowledged
38
+ * rejection means nothing was sent, so the attempt fails under the retry policy.
35
39
  */
36
- export declare function dispatchWorkerEffect(runId: string, plan: WorkerEffectDispatchPlan, worker: EffectWorker | undefined): Effect.Effect<EventEnvelope>;
40
+ export declare function dispatchWorkerEffect(runId: string, plan: WorkerEffectDispatchPlan, worker: EffectWorker | undefined, now: number): Effect.Effect<EventEnvelope>;
41
+ /**
42
+ * An acknowledged transport can lose a message it accepted, for example to a dead-letter queue.
43
+ * Only a schedule-to-start timeout notices, so every effect such a worker claims needs one.
44
+ */
45
+ export declare function assertWorkerDeadlines(registry: ComposedRegistry, worker: EffectWorker | undefined): void;
@@ -1,7 +1,8 @@
1
1
  import { Effect, Predicate } from 'effect';
2
- import { createEvent, isPrimitiveEffect, } from '@swirls/looms/core';
2
+ import { createEvent, deferredUntil, isPrimitiveEffect, } from '@swirls/looms/core';
3
3
  import { RuntimeExecutionError } from './errors.js';
4
4
  import { workerTask } from './helpers.js';
5
+ import { planAttemptFailure } from './retry-policy.js';
5
6
  function workerClaims(worker, runId, item) {
6
7
  if (!worker.handles && !worker.canDispatch) {
7
8
  return undefined;
@@ -52,25 +53,30 @@ export function isWorkerEffect(input) {
52
53
  return worker !== undefined && claim !== false;
53
54
  }
54
55
  export function planWorkerEffects(input) {
55
- return input.state.outstandingEffects
56
- .filter((item) => {
57
- const execution = input.state.effectExecutions[item.effectId];
58
- if (!isWorkerEffect({ ...input, item, execution })) {
56
+ const workerItems = input.state.outstandingEffects.filter((item) => isWorkerEffect({ ...input, item, execution: input.state.effectExecutions[item.effectId] }));
57
+ const due = workerItems.filter((item) => {
58
+ const dueAt = deferredUntil(item.effect);
59
+ if (dueAt !== undefined && dueAt > input.now + 5) {
59
60
  return false;
60
61
  }
62
+ const execution = input.state.effectExecutions[item.effectId];
61
63
  return (!execution ||
62
64
  execution.status === 'queued' ||
63
65
  (execution.status === 'retry_wait' &&
64
66
  execution.nextAttemptAt !== null &&
65
67
  execution.nextAttemptAt <= input.now + 5));
66
- })
67
- .map((item) => {
68
+ });
69
+ return withinInFlightLimit(input, workerItems, due).map((item) => {
68
70
  const existing = input.state.effectExecutions[item.effectId];
69
71
  const attempt = existing?.status === 'queued' ? existing.attempt : (existing?.attempt ?? 0) + 1;
70
72
  const retry = input.registry.effects.get(item.effect.type)?.retry;
73
+ const scheduleToStartDeadlineAt = retry?.scheduleToStartTimeoutMs
74
+ ? input.now + retry.scheduleToStartTimeoutMs
75
+ : null;
71
76
  return {
72
77
  item,
73
78
  attempt,
79
+ retry,
74
80
  queuedEvent: existing?.status === 'queued'
75
81
  ? undefined
76
82
  : createEvent(input.runId, {
@@ -80,19 +86,41 @@ export function planWorkerEffects(input) {
80
86
  causationId: item.causingEventId,
81
87
  origin: { type: 'system' },
82
88
  }),
83
- task: workerTask(input.runId, item, attempt),
84
- scheduleToStartDeadlineAt: retry?.scheduleToStartTimeoutMs
85
- ? input.now + retry.scheduleToStartTimeoutMs
86
- : null,
89
+ task: workerTask(input.runId, item, attempt, {
90
+ details: existing?.heartbeatDetails,
91
+ secrets: input.secrets,
92
+ trace: input.state.startIdentity?.trace,
93
+ retry,
94
+ scheduleToStartAt: scheduleToStartDeadlineAt,
95
+ }),
96
+ scheduleToStartDeadlineAt,
87
97
  };
88
98
  });
89
99
  }
100
+ const IN_FLIGHT_STATUSES = new Set([
101
+ 'dispatched',
102
+ 'started',
103
+ 'heartbeat',
104
+ 'cancel_requested',
105
+ ]);
106
+ function withinInFlightLimit(input, workerItems, due) {
107
+ const max = input.maxInFlightWorkerEffects;
108
+ if (max === undefined || max <= 0) {
109
+ return due;
110
+ }
111
+ const inFlight = workerItems.filter((item) => {
112
+ const status = input.state.effectExecutions[item.effectId]?.status;
113
+ return status !== undefined && IN_FLIGHT_STATUSES.has(status);
114
+ }).length;
115
+ return due.slice(0, Math.max(0, max - inFlight));
116
+ }
90
117
  /**
91
- * Hand a planned attempt to the worker. A rejected dispatch may still have reached the worker, so
92
- * with a schedule-to-start deadline it is recorded as dispatched and recovered by that timeout;
93
- * without one it is parked as ambiguous for an operator.
118
+ * Hand a planned attempt to the worker. When an unacknowledged dispatch rejects, the task may still
119
+ * have reached the worker, so with a schedule-to-start deadline it is recorded as dispatched and
120
+ * recovered by that timeout; without one it is parked as ambiguous for an operator. An acknowledged
121
+ * rejection means nothing was sent, so the attempt fails under the retry policy.
94
122
  */
95
- export function dispatchWorkerEffect(runId, plan, worker) {
123
+ export function dispatchWorkerEffect(runId, plan, worker, now) {
96
124
  const dispatched = createEvent(runId, {
97
125
  type: 'runtime.effect.dispatched',
98
126
  payload: {
@@ -118,9 +146,47 @@ export function dispatchWorkerEffect(runId, plan, worker) {
118
146
  try: () => Promise.resolve(worker.dispatch(plan.task)),
119
147
  catch: (cause) => new RuntimeExecutionError(cause instanceof Error ? cause.message : String(cause)),
120
148
  }).pipe(Effect.match({
121
- onFailure: (error) => plan.scheduleToStartDeadlineAt === null
122
- ? ambiguous(`worker dispatch outcome is ambiguous: ${error.message}`)
123
- : dispatched,
149
+ onFailure: (error) => {
150
+ if (worker.dispatchSemantics === 'acknowledged') {
151
+ return planAttemptFailure({
152
+ runId,
153
+ item: plan.item,
154
+ attempt: plan.attempt,
155
+ error: `worker dispatch failed: ${error.message}`,
156
+ now,
157
+ retry: plan.retry,
158
+ origin: { type: 'system' },
159
+ }).event;
160
+ }
161
+ return plan.scheduleToStartDeadlineAt === null
162
+ ? ambiguous(`worker dispatch outcome is ambiguous: ${error.message}`)
163
+ : dispatched;
164
+ },
124
165
  onSuccess: () => dispatched,
125
166
  }));
126
167
  }
168
+ /**
169
+ * An acknowledged transport can lose a message it accepted, for example to a dead-letter queue.
170
+ * Only a schedule-to-start timeout notices, so every effect such a worker claims needs one.
171
+ */
172
+ export function assertWorkerDeadlines(registry, worker) {
173
+ if (worker?.dispatchSemantics !== 'acknowledged') {
174
+ return;
175
+ }
176
+ const missing = [...registry.effects.values()]
177
+ .filter((definition) => claimsType(worker, definition) && !definition.retry?.scheduleToStartTimeoutMs)
178
+ .map((definition) => definition.type);
179
+ if (missing.length > 0) {
180
+ throw new Error(`An acknowledged worker claims effects without retry.scheduleToStartTimeoutMs: ${missing.join(', ')}`);
181
+ }
182
+ }
183
+ function claimsType(worker, definition) {
184
+ if (definition.execution) {
185
+ return definition.execution === 'worker';
186
+ }
187
+ const { handles } = worker;
188
+ const handled = Predicate.isFunction(handles)
189
+ ? handles(definition.type)
190
+ : (handles?.includes(definition.type) ?? false);
191
+ return handled || !definition.hasHandler;
192
+ }
@@ -0,0 +1,4 @@
1
+ import type { DebuggerPlugin } from '@swirls/looms/debugger';
2
+ import { ScheduleView } from './schedule-view';
3
+ export declare const scheduleDebugger: DebuggerPlugin;
4
+ export { ScheduleView };
@@ -0,0 +1,11 @@
1
+ import { scheduleStatus } from '../projections.js';
2
+ import { ScheduleView } from './schedule-view.js';
3
+ import { summarizeScheduleEvent } from './summarize.js';
4
+ export const scheduleDebugger = {
5
+ name: 'schedule',
6
+ families: [
7
+ { family: 'schedule', color: 'oklch(0.72 0.12 210)', summarize: summarizeScheduleEvent },
8
+ ],
9
+ projections: [{ name: scheduleStatus.name, component: ScheduleView }],
10
+ };
11
+ export { ScheduleView };
@@ -0,0 +1,3 @@
1
+ export declare function ScheduleView({ runId }: {
2
+ runId: string;
3
+ }): import("react").JSX.Element;
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Button, StatusBadge, useDebugger } from '@swirls/looms/debugger';
4
+ import { useProjection, useRunStore } from '@swirls/looms/react';
5
+ import { scheduleStatus } from '../projections.js';
6
+ import { pause, resume, trigger } from '../signals.js';
7
+ import { describeSpec, nextFireAt } from '../specs.js';
8
+ export function ScheduleView({ runId }) {
9
+ const { client, openRun } = useDebugger();
10
+ const store = useRunStore(runId);
11
+ const state = useProjection(store, scheduleStatus);
12
+ const now = useNow(state.status === 'running' && state.nextAt !== null);
13
+ if (!state.spec && state.recent.length === 0) {
14
+ return _jsx("p", { className: "text-muted-foreground text-xs", children: "No schedule yet." });
15
+ }
16
+ const upcoming = state.spec ? upcomingFires(state.spec, state.nextAt) : [];
17
+ const remaining = state.nextAt === null ? null : state.nextAt - now;
18
+ return (_jsxs("div", { className: "grid gap-3 text-xs", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate font-medium", children: state.spec ? describeSpec(state.spec) : 'Schedule' }), _jsx(StatusBadge, { status: state.status })] }), _jsx("p", { className: "text-muted-foreground", children: state.status === 'paused' || state.status === 'retired'
19
+ ? 'No alarm is set.'
20
+ : remaining === null
21
+ ? 'Nothing further is scheduled.'
22
+ : `Next fire ${formatRemaining(remaining)}.` }), state.status === 'running' || state.status === 'paused' ? (_jsxs("div", { className: "flex gap-1.5", children: [state.status === 'paused' ? (_jsx(Button, { size: "xs", onClick: () => void client.signal(runId, [resume()]), children: "Resume" })) : (_jsx(Button, { size: "xs", variant: "outline", onClick: () => void client.signal(runId, [pause()]), children: "Pause" })), _jsx(Button, { size: "xs", variant: "outline", onClick: () => void client.signal(runId, [trigger()]), children: "Fire now" })] })) : null, upcoming.length > 0 ? (_jsx("ol", { className: "text-muted-foreground grid gap-1", children: upcoming.map((at) => (_jsx("li", { children: formatInstant(at) }, at))) })) : null, state.recent.length > 0 ? (_jsx("ul", { className: "grid gap-1", children: state.recent.map((item, index) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground w-14 shrink-0", children: item.outcome === 'fired' ? 'Fired' : 'Skipped' }), item.runId && openRun ? (_jsx("button", { type: "button", className: "truncate text-left underline-offset-2 hover:underline", onClick: () => openRun(item.runId ?? ''), children: item.runId })) : (_jsx("span", { className: "truncate", children: item.reason ?? item.runId ?? 'held' }))] }, `${item.at}-${index}`))) })) : null] }));
23
+ }
24
+ function upcomingFires(spec, nextAt) {
25
+ if (nextAt === null) {
26
+ return [];
27
+ }
28
+ const times = [nextAt];
29
+ let cursor = nextAt;
30
+ while (times.length < 5) {
31
+ const next = nextFireAt(spec, cursor);
32
+ if (next === null) {
33
+ break;
34
+ }
35
+ times.push(next);
36
+ cursor = next;
37
+ }
38
+ return times;
39
+ }
40
+ function useNow(active) {
41
+ const [now, setNow] = useState(() => Date.now());
42
+ useEffect(() => {
43
+ if (!active) {
44
+ return undefined;
45
+ }
46
+ setNow(Date.now());
47
+ const timer = setInterval(() => setNow(Date.now()), 1_000);
48
+ return () => clearInterval(timer);
49
+ }, [active]);
50
+ return now;
51
+ }
52
+ function formatRemaining(ms) {
53
+ if (ms <= 0) {
54
+ return 'is due';
55
+ }
56
+ const seconds = Math.ceil(ms / 1_000);
57
+ if (seconds < 60) {
58
+ return `in ${seconds}s`;
59
+ }
60
+ const minutes = Math.floor(seconds / 60);
61
+ if (minutes < 60) {
62
+ return `in ${minutes}m`;
63
+ }
64
+ return `in ${Math.floor(minutes / 60)}h`;
65
+ }
66
+ function formatInstant(at) {
67
+ return new Date(at).toISOString().replace('.000Z', 'Z');
68
+ }
@@ -0,0 +1,2 @@
1
+ import { type DebuggerEvent, type EventSummary } from '@swirls/looms/debugger';
2
+ export declare function summarizeScheduleEvent(event: DebuggerEvent): EventSummary | undefined;
@@ -0,0 +1,43 @@
1
+ import { jsonFields, jsonNumberText, jsonText, } from '@swirls/looms/debugger';
2
+ import { describeSpec } from '../specs.js';
3
+ export function summarizeScheduleEvent(event) {
4
+ const payload = jsonFields(event.payload);
5
+ switch (event.type) {
6
+ case 'schedule.planned':
7
+ return { title: 'fire planned', detail: jsonNumberText(payload.nextAt) };
8
+ case 'schedule.fired':
9
+ return {
10
+ title: payload.manual === true ? 'fired manually' : 'fired',
11
+ detail: jsonText(payload.runId),
12
+ };
13
+ case 'schedule.skipped':
14
+ return { title: 'fire skipped', detail: jsonText(payload.reason) };
15
+ case 'schedule.updated':
16
+ return {
17
+ title: 'schedule updated',
18
+ detail: payload.spec === undefined ? undefined : specDetail(payload.spec),
19
+ };
20
+ case 'schedule.paused':
21
+ return { title: 'schedule paused' };
22
+ case 'schedule.resumed':
23
+ return { title: 'schedule resumed' };
24
+ case 'schedule.retired':
25
+ return { title: 'schedule retired' };
26
+ case 'schedule.triggered':
27
+ return { title: 'manual fire requested' };
28
+ case 'schedule.rejected':
29
+ return { title: 'update rejected', detail: jsonText(payload.reason) };
30
+ default:
31
+ return undefined;
32
+ }
33
+ }
34
+ function specDetail(value) {
35
+ if (!isSpec(value)) {
36
+ return undefined;
37
+ }
38
+ return describeSpec(value);
39
+ }
40
+ function isSpec(value) {
41
+ const fields = jsonFields(value);
42
+ return fields.type === 'cron' || fields.type === 'every' || fields.type === 'once';
43
+ }
@@ -0,0 +1,10 @@
1
+ import { Context, Layer } from 'effect';
2
+ import { type DefinitionStore } from '@swirls/looms/core';
3
+ import type { ScheduleDefinition } from './definitions';
4
+ export type ScheduleDefinitionStore = DefinitionStore<ScheduleDefinition>;
5
+ declare const ScheduleDefinitionsTag_base: Context.ServiceClass<ScheduleDefinitionsTag, "looms/ScheduleDefinitions", ScheduleDefinitionStore>;
6
+ export declare class ScheduleDefinitionsTag extends ScheduleDefinitionsTag_base {
7
+ }
8
+ export declare function makeScheduleDefinitionStore(definitions: ReadonlyArray<ScheduleDefinition>): ScheduleDefinitionStore;
9
+ export declare const ScheduleDefinitionsLive: (definitions: ScheduleDefinitionStore) => Layer.Layer<ScheduleDefinitionsTag, never, never>;
10
+ export {};
@@ -0,0 +1,8 @@
1
+ import { Context, Layer } from 'effect';
2
+ import { makeDefinitionStore } from '@swirls/looms/core';
3
+ export class ScheduleDefinitionsTag extends Context.Service()('looms/ScheduleDefinitions') {
4
+ }
5
+ export function makeScheduleDefinitionStore(definitions) {
6
+ return makeDefinitionStore('schedule', definitions);
7
+ }
8
+ export const ScheduleDefinitionsLive = (definitions) => Layer.succeed(ScheduleDefinitionsTag, definitions);
@@ -0,0 +1,57 @@
1
+ import { Schema } from 'effect';
2
+ import { type DefinitionRef, type JsonValue } from '@swirls/looms/core';
3
+ import { type ScheduleSpec } from './specs';
4
+ export declare const ScheduleMisfireSchema: Schema.Union<readonly [Schema.Literal<"fire-once">, Schema.Literal<"skip">]>;
5
+ export type ScheduleMisfire = Schema.Schema.Type<typeof ScheduleMisfireSchema>;
6
+ export declare const ScheduleOverlapSchema: Schema.Union<readonly [Schema.Literal<"allow">, Schema.Literal<"skip">]>;
7
+ export type ScheduleOverlap = Schema.Schema.Type<typeof ScheduleOverlapSchema>;
8
+ export declare const ScheduleStartSchema: Schema.Union<readonly [Schema.Struct<{
9
+ readonly spec: Schema.optional<Schema.Union<readonly [Schema.Struct<{
10
+ readonly type: Schema.Literal<"cron">;
11
+ readonly expr: Schema.String;
12
+ readonly tz: Schema.optional<Schema.String>;
13
+ }>, Schema.Struct<{
14
+ readonly type: Schema.Literal<"every">;
15
+ readonly everyMs: Schema.Finite;
16
+ readonly anchor: Schema.optional<Schema.Finite>;
17
+ }>, Schema.Struct<{
18
+ readonly type: Schema.Literal<"once">;
19
+ readonly at: Schema.Finite;
20
+ }>]>>;
21
+ readonly input: Schema.optional<Schema.NullOr<Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
22
+ readonly startAt: Schema.optional<Schema.NullOr<Schema.Finite>>;
23
+ readonly endAt: Schema.optional<Schema.NullOr<Schema.Finite>>;
24
+ readonly maxFires: Schema.optional<Schema.NullOr<Schema.Finite>>;
25
+ }>, Schema.Null]>;
26
+ export type ScheduleStart = Schema.Schema.Type<typeof ScheduleStartSchema>;
27
+ /** What a fire effect knows when it builds the target input or asks `beforeFire`. */
28
+ export interface FireContext {
29
+ readonly at: number;
30
+ readonly input: JsonValue | null;
31
+ readonly runId: string;
32
+ readonly manual: boolean;
33
+ }
34
+ export type BeforeFireResult = 'fire' | {
35
+ readonly skip: string;
36
+ };
37
+ export interface ScheduleDefinition<TName extends string = string> extends DefinitionRef {
38
+ readonly kind: 'schedule';
39
+ readonly name: TName;
40
+ readonly version: string;
41
+ readonly description?: string;
42
+ readonly input?: typeof ScheduleStartSchema;
43
+ readonly target: DefinitionRef;
44
+ readonly spec: ScheduleSpec;
45
+ readonly misfire: ScheduleMisfire;
46
+ readonly overlap: ScheduleOverlap;
47
+ readonly fireInput?: (ctx: FireContext) => JsonValue | Promise<JsonValue>;
48
+ readonly beforeFire?: (ctx: FireContext) => BeforeFireResult | Promise<BeforeFireResult>;
49
+ }
50
+ export type AnyScheduleDefinition = ScheduleDefinition;
51
+ export declare function defineSchedule<TName extends string>(def: Omit<ScheduleDefinition<TName>, 'kind' | 'version' | 'input' | 'misfire' | 'overlap' | 'fireInput'> & {
52
+ readonly version?: string;
53
+ readonly misfire?: ScheduleMisfire;
54
+ readonly overlap?: ScheduleOverlap;
55
+ /** Builds the target run's input at fire time. Defaults to the stored start input. */
56
+ readonly input?: (ctx: FireContext) => JsonValue | Promise<JsonValue>;
57
+ }): ScheduleDefinition<TName>;