@yolk-sdk/harness 0.1.0-canary.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/dist/coordinator.d.mts +62 -0
- package/dist/coordinator.d.mts.map +1 -0
- package/dist/coordinator.mjs +129 -0
- package/dist/coordinator.mjs.map +1 -0
- package/dist/driver/durable-object.d.mts +20 -0
- package/dist/driver/durable-object.d.mts.map +1 -0
- package/dist/driver/durable-object.mjs +12 -0
- package/dist/driver/durable-object.mjs.map +1 -0
- package/dist/driver/memory.d.mts +21 -0
- package/dist/driver/memory.d.mts.map +1 -0
- package/dist/driver/memory.mjs +15 -0
- package/dist/driver/memory.mjs.map +1 -0
- package/dist/driver.d.mts +67 -0
- package/dist/driver.d.mts.map +1 -0
- package/dist/driver.mjs +95 -0
- package/dist/driver.mjs.map +1 -0
- package/dist/inbox.d.mts +91 -0
- package/dist/inbox.d.mts.map +1 -0
- package/dist/inbox.mjs +224 -0
- package/dist/inbox.mjs.map +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +1 -0
- package/dist/outcome.d.mts +61 -0
- package/dist/outcome.d.mts.map +1 -0
- package/dist/outcome.mjs +159 -0
- package/dist/outcome.mjs.map +1 -0
- package/dist/store.d.mts +25 -0
- package/dist/store.d.mts.map +1 -0
- package/dist/store.mjs +86 -0
- package/dist/store.mjs.map +1 -0
- package/package.json +90 -0
- package/src/coordinator.ts +254 -0
- package/src/driver/durable-object.ts +34 -0
- package/src/driver/memory.ts +38 -0
- package/src/driver.ts +250 -0
- package/src/inbox.ts +454 -0
- package/src/index.ts +2 -0
- package/src/outcome.ts +316 -0
- package/src/store.ts +137 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Layer } from 'effect'
|
|
2
|
+
import type { Effect } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
makeDriverLayer,
|
|
5
|
+
type Drain,
|
|
6
|
+
type Driver,
|
|
7
|
+
type InvalidMaxResumeAttempts
|
|
8
|
+
} from '../driver.ts'
|
|
9
|
+
import { makeInMemoryInboxLayer } from '../inbox.ts'
|
|
10
|
+
import type { Inbox } from '../inbox.ts'
|
|
11
|
+
import { makeSnapshotRunStoreLayer, type DurableRunStoreSnapshot, type RunStore } from '../store.ts'
|
|
12
|
+
|
|
13
|
+
export type { DurableRunStoreSnapshot }
|
|
14
|
+
|
|
15
|
+
type DurableObjectDriverBase = {
|
|
16
|
+
readonly load: Effect.Effect<DurableRunStoreSnapshot | undefined>
|
|
17
|
+
readonly save: (snapshot: DurableRunStoreSnapshot) => Effect.Effect<void>
|
|
18
|
+
readonly drain?: Drain
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function makeDurableObjectDriverLayer(
|
|
22
|
+
options: DurableObjectDriverBase & { readonly maxResumeAttempts?: undefined }
|
|
23
|
+
): Layer.Layer<Driver | RunStore | Inbox>
|
|
24
|
+
export function makeDurableObjectDriverLayer(
|
|
25
|
+
options: DurableObjectDriverBase & { readonly maxResumeAttempts?: number }
|
|
26
|
+
): Layer.Layer<Driver | RunStore | Inbox, InvalidMaxResumeAttempts>
|
|
27
|
+
export function makeDurableObjectDriverLayer(
|
|
28
|
+
options: DurableObjectDriverBase & { readonly maxResumeAttempts?: number }
|
|
29
|
+
) {
|
|
30
|
+
return makeDriverLayer(options).pipe(
|
|
31
|
+
Layer.provideMerge(makeSnapshotRunStoreLayer(options)),
|
|
32
|
+
Layer.provideMerge(makeInMemoryInboxLayer())
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Layer } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
makeDriverLayer,
|
|
4
|
+
type Drain,
|
|
5
|
+
type Driver,
|
|
6
|
+
type DriverLayerOptions,
|
|
7
|
+
type InvalidMaxResumeAttempts
|
|
8
|
+
} from '../driver.ts'
|
|
9
|
+
import { makeInMemoryInboxLayer, type Inbox } from '../inbox.ts'
|
|
10
|
+
import { makeInMemoryRunStoreLayer, type RunStore } from '../store.ts'
|
|
11
|
+
|
|
12
|
+
export function makeInMemoryDriverLayer(): Layer.Layer<Driver, never, RunStore | Inbox>
|
|
13
|
+
export function makeInMemoryDriverLayer(options?: {
|
|
14
|
+
readonly drain?: Drain
|
|
15
|
+
readonly maxResumeAttempts?: undefined
|
|
16
|
+
}): Layer.Layer<Driver, never, RunStore | Inbox>
|
|
17
|
+
export function makeInMemoryDriverLayer(
|
|
18
|
+
options?: DriverLayerOptions
|
|
19
|
+
): Layer.Layer<Driver, InvalidMaxResumeAttempts, RunStore | Inbox>
|
|
20
|
+
export function makeInMemoryDriverLayer(options?: DriverLayerOptions) {
|
|
21
|
+
return options === undefined ? makeDriverLayer() : makeDriverLayer(options)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function makeInMemoryHarnessLayer(): Layer.Layer<Driver | RunStore | Inbox>
|
|
25
|
+
export function makeInMemoryHarnessLayer(options?: {
|
|
26
|
+
readonly drain?: Drain
|
|
27
|
+
readonly maxResumeAttempts?: undefined
|
|
28
|
+
}): Layer.Layer<Driver | RunStore | Inbox>
|
|
29
|
+
export function makeInMemoryHarnessLayer(
|
|
30
|
+
options?: DriverLayerOptions
|
|
31
|
+
): Layer.Layer<Driver | RunStore | Inbox, InvalidMaxResumeAttempts>
|
|
32
|
+
export function makeInMemoryHarnessLayer(options?: DriverLayerOptions) {
|
|
33
|
+
const driver = options === undefined ? makeDriverLayer() : makeDriverLayer(options)
|
|
34
|
+
return driver.pipe(
|
|
35
|
+
Layer.provideMerge(makeInMemoryRunStoreLayer()),
|
|
36
|
+
Layer.provideMerge(makeInMemoryInboxLayer())
|
|
37
|
+
)
|
|
38
|
+
}
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { Cause, Context, Data, Effect, Exit, Layer, Semaphore } from 'effect'
|
|
2
|
+
import { makeCoordinator, type Promotable } from './coordinator.ts'
|
|
3
|
+
import {
|
|
4
|
+
Inbox,
|
|
5
|
+
type HitlAdmission,
|
|
6
|
+
type HitlDecision,
|
|
7
|
+
type InboxItem,
|
|
8
|
+
type ParkedResponse,
|
|
9
|
+
type PauseDecision
|
|
10
|
+
} from './inbox.ts'
|
|
11
|
+
import { RunStore } from './store.ts'
|
|
12
|
+
|
|
13
|
+
export type InterruptReason = 'user' | 'shutdown'
|
|
14
|
+
|
|
15
|
+
export const defaultMaxResumeAttempts = 10
|
|
16
|
+
|
|
17
|
+
export class InvalidMaxResumeAttempts extends Data.TaggedError('InvalidMaxResumeAttempts')<{
|
|
18
|
+
readonly maxResumeAttempts: number
|
|
19
|
+
}> {}
|
|
20
|
+
|
|
21
|
+
const isValidMaxResumeAttempts = (value: number) => Number.isSafeInteger(value) && value >= 0
|
|
22
|
+
|
|
23
|
+
export type ResumeSuspendedResult = {
|
|
24
|
+
readonly resumed: ReadonlyArray<string>
|
|
25
|
+
readonly exhausted: ReadonlyArray<string>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DrainContext = {
|
|
29
|
+
readonly drainToken: string
|
|
30
|
+
readonly readyResponses: ReadonlyArray<ParkedResponse>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Drain = (
|
|
34
|
+
runId: string,
|
|
35
|
+
force: boolean,
|
|
36
|
+
scope: Promotable,
|
|
37
|
+
context: DrainContext
|
|
38
|
+
) => Effect.Effect<void, never, Inbox | RunStore>
|
|
39
|
+
|
|
40
|
+
export type DriverLayerOptions = {
|
|
41
|
+
readonly drain?: Drain
|
|
42
|
+
readonly maxResumeAttempts?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type StopDecision =
|
|
46
|
+
| { readonly _tag: 'Interrupted' }
|
|
47
|
+
| { readonly _tag: 'ParkCleared' }
|
|
48
|
+
| { readonly _tag: 'Idle' }
|
|
49
|
+
|
|
50
|
+
export type DriverShape = {
|
|
51
|
+
readonly active: Effect.Effect<ReadonlySet<string>>
|
|
52
|
+
readonly isActive: (runId: string) => Effect.Effect<boolean>
|
|
53
|
+
readonly run: (runId: string) => Effect.Effect<void>
|
|
54
|
+
readonly wake: (runId: string, scope?: Promotable) => Effect.Effect<void>
|
|
55
|
+
readonly interrupt: (
|
|
56
|
+
runId: string,
|
|
57
|
+
options?: { readonly reason?: InterruptReason; readonly awaitSettlement?: boolean }
|
|
58
|
+
) => Effect.Effect<boolean>
|
|
59
|
+
readonly awaitIdle: (runId: string) => Effect.Effect<void>
|
|
60
|
+
readonly resumeSuspended: Effect.Effect<ResumeSuspendedResult>
|
|
61
|
+
readonly admit: (item: InboxItem) => Effect.Effect<void>
|
|
62
|
+
readonly pause: (
|
|
63
|
+
runId: string,
|
|
64
|
+
requestIds: ReadonlyArray<string>,
|
|
65
|
+
drainToken: string
|
|
66
|
+
) => Effect.Effect<PauseDecision>
|
|
67
|
+
readonly resumeHitl: (runId: string, admission: HitlAdmission) => Effect.Effect<HitlDecision>
|
|
68
|
+
readonly stop: (runId: string) => Effect.Effect<StopDecision>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class Driver extends Context.Service<Driver, DriverShape>()('@yolk-sdk/harness/Driver') {}
|
|
72
|
+
|
|
73
|
+
export const makeHarness = <DE, DR, SE, SR, IE, IR>(input: {
|
|
74
|
+
readonly driver: Layer.Layer<Driver, DE, DR>
|
|
75
|
+
readonly store: Layer.Layer<RunStore, SE, SR>
|
|
76
|
+
readonly inbox: Layer.Layer<Inbox, IE, IR>
|
|
77
|
+
}): Layer.Layer<Driver | RunStore | Inbox, DE | SE | IE, DR | SR | IR> =>
|
|
78
|
+
Layer.mergeAll(input.driver, input.store, input.inbox)
|
|
79
|
+
|
|
80
|
+
export const admit = (item: InboxItem) => Effect.flatMap(Driver, driver => driver.admit(item))
|
|
81
|
+
|
|
82
|
+
const makeValidatedDriverLayer = (
|
|
83
|
+
maxResumeAttempts: number,
|
|
84
|
+
hostDrain: Drain
|
|
85
|
+
): Layer.Layer<Driver, never, RunStore | Inbox> =>
|
|
86
|
+
Layer.effect(
|
|
87
|
+
Driver,
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
const store = yield* RunStore
|
|
90
|
+
const inbox = yield* Inbox
|
|
91
|
+
const sweep = yield* Semaphore.make(1)
|
|
92
|
+
const acquired = new Set<string>()
|
|
93
|
+
const coordinator = yield* makeCoordinator<string, never, InterruptReason>({
|
|
94
|
+
drain: (runId, force, scope) =>
|
|
95
|
+
inbox.beginDrain(runId, scope).pipe(
|
|
96
|
+
Effect.flatMap(begun => {
|
|
97
|
+
if (begun._tag === 'Skip') return Effect.void
|
|
98
|
+
return Effect.suspend(() =>
|
|
99
|
+
hostDrain(runId, force, scope, {
|
|
100
|
+
drainToken: begun.drainToken,
|
|
101
|
+
readyResponses: begun.readyResponses
|
|
102
|
+
})
|
|
103
|
+
).pipe(
|
|
104
|
+
Effect.provideService(Inbox, inbox),
|
|
105
|
+
Effect.provideService(RunStore, store),
|
|
106
|
+
Effect.onExit(exit => inbox.endDrain(runId, begun.drainToken, Exit.isSuccess(exit)))
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
),
|
|
110
|
+
started: runId =>
|
|
111
|
+
store.claim(runId).pipe(
|
|
112
|
+
Effect.tap(() =>
|
|
113
|
+
Effect.sync(() => {
|
|
114
|
+
acquired.add(runId)
|
|
115
|
+
})
|
|
116
|
+
)
|
|
117
|
+
),
|
|
118
|
+
settled: (runId, exit, reason) =>
|
|
119
|
+
Effect.suspend(() => {
|
|
120
|
+
const owned = acquired.delete(runId)
|
|
121
|
+
if (
|
|
122
|
+
owned &&
|
|
123
|
+
(reason === 'user' || (reason === undefined && !Exit.hasInterrupts(exit)))
|
|
124
|
+
) {
|
|
125
|
+
return store.release(runId)
|
|
126
|
+
}
|
|
127
|
+
// Acquisition receipt guards automatic failed-start settlement only.
|
|
128
|
+
// Explicit user-terminal authority still releases a leftover claim
|
|
129
|
+
// after this owner has actually settled, matching Idle/Settling stop.
|
|
130
|
+
if (reason === 'user' && !owned) {
|
|
131
|
+
return store
|
|
132
|
+
.isClaimed(runId)
|
|
133
|
+
.pipe(Effect.flatMap(claimed => (claimed ? store.release(runId) : Effect.void)))
|
|
134
|
+
}
|
|
135
|
+
return Effect.void
|
|
136
|
+
}).pipe(
|
|
137
|
+
Effect.exit,
|
|
138
|
+
Effect.flatMap(settledExit => {
|
|
139
|
+
if (Exit.isSuccess(settledExit)) return Effect.void
|
|
140
|
+
return Effect.failCause(
|
|
141
|
+
Exit.isFailure(exit)
|
|
142
|
+
? Cause.combine(exit.cause, settledExit.cause)
|
|
143
|
+
: settledExit.cause
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const resumeSuspended = sweep.withPermits(1)(
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const claimed = yield* store.claimed
|
|
152
|
+
const resumed: Array<string> = []
|
|
153
|
+
const exhausted: Array<string> = []
|
|
154
|
+
|
|
155
|
+
for (const runId of claimed) {
|
|
156
|
+
const admission = yield* inbox.admitRecovery(
|
|
157
|
+
runId,
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
if (yield* coordinator.isActive(runId)) return { _tag: 'Skip' } as const
|
|
160
|
+
if (!(yield* store.isClaimed(runId))) return { _tag: 'Skip' } as const
|
|
161
|
+
const count = yield* store.resumeCount(runId)
|
|
162
|
+
if (count >= maxResumeAttempts) {
|
|
163
|
+
yield* store.release(runId)
|
|
164
|
+
return { _tag: 'Exhausted' } as const
|
|
165
|
+
}
|
|
166
|
+
yield* store.incrementResumeCount(runId)
|
|
167
|
+
return { _tag: 'Resume' } as const
|
|
168
|
+
}),
|
|
169
|
+
coordinator.wake(runId, 'input')
|
|
170
|
+
)
|
|
171
|
+
if (admission._tag === 'Resumed') resumed.push(runId)
|
|
172
|
+
else if (admission._tag === 'Exhausted') exhausted.push(runId)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { resumed, exhausted }
|
|
176
|
+
})
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return Driver.of({
|
|
180
|
+
active: coordinator.active,
|
|
181
|
+
isActive: coordinator.isActive,
|
|
182
|
+
run: runId => {
|
|
183
|
+
const continueRun = (): Effect.Effect<void> =>
|
|
184
|
+
inbox.startIfUnblocked(runId, coordinator.captureRun(runId)).pipe(
|
|
185
|
+
Effect.flatMap(ticket => {
|
|
186
|
+
if (ticket === undefined) return Effect.void
|
|
187
|
+
if (ticket._tag === 'Stopping') {
|
|
188
|
+
return ticket.awaitSettlement.pipe(Effect.andThen(Effect.suspend(continueRun)))
|
|
189
|
+
}
|
|
190
|
+
return ticket.join
|
|
191
|
+
})
|
|
192
|
+
)
|
|
193
|
+
return continueRun()
|
|
194
|
+
},
|
|
195
|
+
wake: (runId, scope = 'input') =>
|
|
196
|
+
inbox.wakeIfUnblocked(runId, scope, coordinator.wake(runId, scope)).pipe(Effect.asVoid),
|
|
197
|
+
interrupt: (runId, interruptOptions) =>
|
|
198
|
+
coordinator.interrupt(runId, interruptOptions?.reason ?? 'user', interruptOptions),
|
|
199
|
+
awaitIdle: coordinator.awaitIdle,
|
|
200
|
+
resumeSuspended,
|
|
201
|
+
admit: item =>
|
|
202
|
+
inbox.enqueueAndWake(
|
|
203
|
+
item,
|
|
204
|
+
coordinator.wake(item.runId, item.delivery === 'steer' ? 'steer' : 'input')
|
|
205
|
+
),
|
|
206
|
+
pause: (runId, requestIds, drainToken) => inbox.park(runId, requestIds, drainToken),
|
|
207
|
+
resumeHitl: (runId, admission) =>
|
|
208
|
+
inbox.acceptHitl(runId, admission, coordinator.wake(runId, 'input')),
|
|
209
|
+
stop: runId =>
|
|
210
|
+
inbox
|
|
211
|
+
.invalidate(
|
|
212
|
+
runId,
|
|
213
|
+
coordinator
|
|
214
|
+
.terminalStop(runId, 'user')
|
|
215
|
+
.pipe(
|
|
216
|
+
Effect.map(
|
|
217
|
+
receipt => receipt._tag === 'Interrupted' || receipt._tag === 'LiveStopping'
|
|
218
|
+
)
|
|
219
|
+
),
|
|
220
|
+
store
|
|
221
|
+
.isClaimed(runId)
|
|
222
|
+
.pipe(Effect.flatMap(claimed => (claimed ? store.release(runId) : Effect.void)))
|
|
223
|
+
)
|
|
224
|
+
.pipe(
|
|
225
|
+
Effect.map(result => {
|
|
226
|
+
if (result.interrupted) return { _tag: 'Interrupted' } as const
|
|
227
|
+
if (result.hadPark) return { _tag: 'ParkCleared' } as const
|
|
228
|
+
return { _tag: 'Idle' } as const
|
|
229
|
+
})
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
export function makeDriverLayer(): Layer.Layer<Driver, never, RunStore | Inbox>
|
|
236
|
+
export function makeDriverLayer(options?: {
|
|
237
|
+
readonly drain?: Drain
|
|
238
|
+
readonly maxResumeAttempts?: undefined
|
|
239
|
+
}): Layer.Layer<Driver, never, RunStore | Inbox>
|
|
240
|
+
export function makeDriverLayer(
|
|
241
|
+
options?: DriverLayerOptions
|
|
242
|
+
): Layer.Layer<Driver, InvalidMaxResumeAttempts, RunStore | Inbox>
|
|
243
|
+
export function makeDriverLayer(options?: DriverLayerOptions) {
|
|
244
|
+
const hostDrain = options?.drain ?? ((_runId, _force, _scope, _context) => Effect.void)
|
|
245
|
+
const maxResumeAttempts = options?.maxResumeAttempts ?? defaultMaxResumeAttempts
|
|
246
|
+
if (!isValidMaxResumeAttempts(maxResumeAttempts)) {
|
|
247
|
+
return Layer.effect(Driver, Effect.fail(new InvalidMaxResumeAttempts({ maxResumeAttempts })))
|
|
248
|
+
}
|
|
249
|
+
return makeValidatedDriverLayer(maxResumeAttempts, hostDrain)
|
|
250
|
+
}
|