@effect-agent/platform-cloudflare 0.1.0-beta.8 → 0.1.0-beta.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Alarm.d.mts +184 -0
- package/dist/Alarm.mjs +451 -0
- package/dist/Alarm.mjs.map +1 -0
- package/dist/BrowserRestCapture.d.mts +35 -0
- package/dist/BrowserRestCapture.mjs +238 -0
- package/dist/BrowserRestCapture.mjs.map +1 -0
- package/dist/BrowserRestCrawl.d.mts +17 -0
- package/dist/BrowserRestCrawl.mjs +244 -0
- package/dist/BrowserRestCrawl.mjs.map +1 -0
- package/dist/CloudflareAiGateway.d.mts +64 -0
- package/dist/CloudflareAiGateway.mjs +70 -0
- package/dist/CloudflareAiGateway.mjs.map +1 -0
- package/dist/CloudflareBindings.d.mts +112 -0
- package/dist/CloudflareBindings.mjs +101 -0
- package/dist/CloudflareBindings.mjs.map +1 -0
- package/dist/CloudflareBrowser-D4YqaTAl.mjs +495 -0
- package/dist/CloudflareBrowser-D4YqaTAl.mjs.map +1 -0
- package/dist/CloudflareBrowser-DJq53niJ.d.mts +80 -0
- package/dist/CloudflareBrowser.d.mts +2 -0
- package/dist/CloudflareBrowser.mjs +2 -0
- package/dist/CloudflareCodeMode.d.mts +44 -0
- package/dist/CloudflareCodeMode.mjs +616 -0
- package/dist/CloudflareCodeMode.mjs.map +1 -0
- package/dist/CloudflareConfig-f3CqTel1.d.mts +101 -0
- package/dist/CloudflareConfig.d.mts +2 -0
- package/dist/CloudflareConfig.mjs +122 -0
- package/dist/CloudflareConfig.mjs.map +1 -0
- package/dist/CloudflareMemory.d.mts +142 -0
- package/dist/CloudflareMemory.mjs +195 -0
- package/dist/CloudflareMemory.mjs.map +1 -0
- package/dist/CloudflareScheduling.d.mts +52 -0
- package/dist/CloudflareScheduling.mjs +389 -0
- package/dist/CloudflareScheduling.mjs.map +1 -0
- package/dist/CloudflareSubscriptions.d.mts +87 -0
- package/dist/CloudflareSubscriptions.mjs +524 -0
- package/dist/CloudflareSubscriptions.mjs.map +1 -0
- package/dist/CloudflareThreadClient.d.mts +1286 -0
- package/dist/CloudflareThreadClient.mjs +386 -0
- package/dist/CloudflareThreadClient.mjs.map +1 -0
- package/dist/InteractiveBrowser.d.mts +144 -0
- package/dist/InteractiveBrowser.mjs +1090 -0
- package/dist/InteractiveBrowser.mjs.map +1 -0
- package/dist/ProtectedBrowser.d.mts +65 -0
- package/dist/ProtectedBrowser.mjs +859 -0
- package/dist/ProtectedBrowser.mjs.map +1 -0
- package/dist/ThreadObject-BpwkEc5E.mjs +836 -0
- package/dist/ThreadObject-BpwkEc5E.mjs.map +1 -0
- package/dist/ThreadObject-NbFOWK1I.d.mts +816 -0
- package/dist/ThreadObject.d.mts +2 -0
- package/dist/ThreadObject.mjs +3 -0
- package/dist/WakeScheduler.d.mts +23 -0
- package/dist/WakeScheduler.mjs +60 -0
- package/dist/WakeScheduler.mjs.map +1 -0
- package/dist/boundary-BguazVkh.mjs +42 -0
- package/dist/boundary-BguazVkh.mjs.map +1 -0
- package/dist/browser-session-lifecycle-CUbVfEgP.mjs +84 -0
- package/dist/browser-session-lifecycle-CUbVfEgP.mjs.map +1 -0
- package/dist/browser-session-lifecycle-DJpqLXy_.d.mts +21 -0
- package/dist/index.d.mts +13 -1547
- package/dist/index.mjs +13 -1636
- package/dist/prepared-admission-DSi55W-7.mjs +73 -0
- package/dist/prepared-admission-DSi55W-7.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -53
- package/src/Alarm.ts +967 -0
- package/src/BrowserRestCapture.ts +477 -0
- package/src/BrowserRestCrawl.ts +540 -0
- package/src/CloudflareAiGateway.ts +170 -0
- package/src/CloudflareBindings.ts +180 -0
- package/src/CloudflareBrowser.ts +15 -0
- package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +372 -204
- package/src/{config.ts → CloudflareConfig.ts} +9 -8
- package/src/CloudflareMemory.ts +406 -0
- package/src/CloudflareScheduling.ts +733 -0
- package/src/CloudflareSubscriptions.ts +1045 -0
- package/src/CloudflareThreadClient.ts +807 -0
- package/src/InteractiveBrowser.ts +2357 -0
- package/src/ProtectedBrowser.ts +2 -0
- package/src/ThreadObject.ts +1179 -0
- package/src/{wake-scheduler.ts → WakeScheduler.ts} +23 -23
- package/src/index.ts +12 -23
- package/src/internal/boundary.ts +55 -0
- package/src/internal/browser-quick-action.ts +857 -0
- package/src/internal/browser-session-lifecycle.ts +160 -0
- package/src/internal/layers.ts +751 -0
- package/src/internal/message-delivery.ts +148 -0
- package/src/internal/prepared-admission.ts +116 -0
- package/src/internal/progress-wait.ts +121 -0
- package/src/internal/transport.ts +42 -0
- package/src/protected-browser/binding.ts +187 -0
- package/src/protected-browser/inspect-frame.ts +125 -0
- package/src/protected-browser/native.ts +429 -0
- package/src/protected-browser/policy.ts +719 -0
- package/dist/index.mjs.map +0 -1
- package/src/alarm.ts +0 -334
- package/src/bindings.ts +0 -145
- package/src/client.ts +0 -646
- package/src/conversation-object.ts +0 -768
- package/src/layers.ts +0 -376
- package/src/transport.ts +0 -38
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { type ThreadId } from "@effect-agent/core/Identifiers";
|
|
2
|
+
import {
|
|
3
|
+
MessageDeliveryDriver,
|
|
4
|
+
MessageDeliveryError,
|
|
5
|
+
MessageDeliveryStore,
|
|
6
|
+
} from "@effect-agent/thread/MessageDelivery";
|
|
7
|
+
import { WakeScheduler } from "@effect-agent/thread/WakeScheduler";
|
|
8
|
+
import { Clock, Context, Deferred, Effect, Layer, Option, Ref, Semaphore, Stream } from "effect";
|
|
9
|
+
|
|
10
|
+
import { DurableAlarmError, ThreadMessageDelivery, ThreadMutationGate } from "../Alarm.ts";
|
|
11
|
+
import { ThreadObjectPlacement } from "../CloudflareBindings.ts";
|
|
12
|
+
import { CloudflareDurableRuntimeConfig } from "../CloudflareConfig.ts";
|
|
13
|
+
|
|
14
|
+
/** Every externally requested write prearms the owning Object's maintenance generation. */
|
|
15
|
+
export const guardedMessageDeliveryStoreLayer = Layer.effect(
|
|
16
|
+
MessageDeliveryStore,
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const store = yield* MessageDeliveryStore;
|
|
19
|
+
const mutations = yield* ThreadMutationGate;
|
|
20
|
+
const wakes = yield* WakeScheduler;
|
|
21
|
+
const { ownsThread } = yield* ThreadObjectPlacement;
|
|
22
|
+
// Reconstructed as unknown on every incarnation. The gate prevents a racing read from
|
|
23
|
+
// caching an empty deadline across a write; SQL remains the recovery authority.
|
|
24
|
+
const deadline = yield* Ref.make<number | null | undefined>(undefined);
|
|
25
|
+
const cacheGate = yield* Semaphore.make(1);
|
|
26
|
+
|
|
27
|
+
const local = <A, E>(
|
|
28
|
+
owner: ThreadId | undefined,
|
|
29
|
+
body: Effect.Effect<A, E>,
|
|
30
|
+
): Effect.Effect<A, E | MessageDeliveryError> =>
|
|
31
|
+
owner === undefined || ownsThread(owner)
|
|
32
|
+
? body
|
|
33
|
+
: Effect.fail(
|
|
34
|
+
MessageDeliveryError.make({ reason: "validation", operation: "message owner" }),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const mutate = <A, E>(body: Effect.Effect<A, E>) =>
|
|
38
|
+
mutations
|
|
39
|
+
.withMutation(cacheGate.withPermit(Ref.set(deadline, undefined).pipe(Effect.andThen(body))))
|
|
40
|
+
.pipe(
|
|
41
|
+
Effect.catchTag("DurableAlarmError", () =>
|
|
42
|
+
MessageDeliveryError.make({ reason: "storage", operation: "prearm message delivery" }),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const nextDeadline = cacheGate.withPermit(
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const cached = yield* Ref.get(deadline);
|
|
49
|
+
|
|
50
|
+
if (cached !== undefined) return cached;
|
|
51
|
+
const current = yield* store.nextDeadline();
|
|
52
|
+
|
|
53
|
+
yield* Ref.set(deadline, current);
|
|
54
|
+
|
|
55
|
+
return current;
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return MessageDeliveryStore.of({
|
|
60
|
+
insert: (record) =>
|
|
61
|
+
local(
|
|
62
|
+
record.key.ownerThreadId,
|
|
63
|
+
mutate(store.insert(record)).pipe(
|
|
64
|
+
Effect.tap(() => wakes.notify(record.key.ownerThreadId)),
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
get: (key) => local(key.ownerThreadId, store.get(key)),
|
|
68
|
+
list: (request) => local(request.ownerThreadId, store.list(request)),
|
|
69
|
+
change: (key, change) => local(key.ownerThreadId, mutate(store.change(key, change))),
|
|
70
|
+
// Only the trusted physical-owner pump omits an owner. All returned keys still
|
|
71
|
+
// pass placement validation before the driver may dispatch any of the wave.
|
|
72
|
+
due: (nowMillis, limit, owner) =>
|
|
73
|
+
local(owner, store.due(nowMillis, limit, owner)).pipe(
|
|
74
|
+
Effect.flatMap((keys) =>
|
|
75
|
+
keys.every((key) => ownsThread(key.ownerThreadId))
|
|
76
|
+
? Effect.succeed(keys)
|
|
77
|
+
: Effect.fail(
|
|
78
|
+
MessageDeliveryError.make({ reason: "validation", operation: "message owner" }),
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
nextDeadline: (owner) =>
|
|
83
|
+
local(owner, owner === undefined ? nextDeadline : store.nextDeadline(owner)),
|
|
84
|
+
});
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
/** Message progress shares the native alarm slot without blocking source runtime work. */
|
|
89
|
+
export const threadMessageDeliveryLayer = Layer.effectContext(
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const driver = yield* MessageDeliveryDriver;
|
|
92
|
+
const store = yield* MessageDeliveryStore;
|
|
93
|
+
const wakes = yield* WakeScheduler;
|
|
94
|
+
const config = yield* CloudflareDurableRuntimeConfig;
|
|
95
|
+
|
|
96
|
+
const failure = (operation: string) => () =>
|
|
97
|
+
DurableAlarmError.make({
|
|
98
|
+
operation,
|
|
99
|
+
message: "Durable message recovery remains pending",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const drain = Effect.gen(function* () {
|
|
103
|
+
const deadline = yield* store.nextDeadline();
|
|
104
|
+
|
|
105
|
+
if (deadline !== null && deadline <= (yield* Clock.currentTimeMillis)) {
|
|
106
|
+
yield* driver.runDue();
|
|
107
|
+
}
|
|
108
|
+
}).pipe(Effect.mapError(failure("drain message delivery")));
|
|
109
|
+
|
|
110
|
+
return Context.make(ThreadMessageDelivery, {
|
|
111
|
+
drain,
|
|
112
|
+
drainUntil: Effect.fn("ThreadMessageDelivery.drainUntil")(function* (
|
|
113
|
+
finished: Deferred.Deferred<void>,
|
|
114
|
+
) {
|
|
115
|
+
// One scoped subscription covers every logical lane in this physical owner.
|
|
116
|
+
// Acquire it before reading durable deadlines; hints remain droppable.
|
|
117
|
+
const notified = (yield* Stream.toPull(wakes.wakes)).pipe(Effect.catch(() => Effect.never));
|
|
118
|
+
|
|
119
|
+
// Always finish one wave; source completion prevents starting subsequent waves.
|
|
120
|
+
yield* Effect.gen(function* () {
|
|
121
|
+
yield* drain;
|
|
122
|
+
|
|
123
|
+
const deadline = yield* store
|
|
124
|
+
.nextDeadline()
|
|
125
|
+
.pipe(Effect.mapError(failure("read message deadline")));
|
|
126
|
+
|
|
127
|
+
// The index includes unfinished waves, lease expiry, retry and settlement polls.
|
|
128
|
+
// Yield at least one millisecond for an already-due deadline instead of spinning.
|
|
129
|
+
const delay =
|
|
130
|
+
deadline === null
|
|
131
|
+
? config.wakeScanInterval
|
|
132
|
+
: Math.min(
|
|
133
|
+
config.wakeScanInterval,
|
|
134
|
+
Math.max(1, deadline - (yield* Clock.currentTimeMillis)),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
yield* Effect.raceFirst(
|
|
138
|
+
Deferred.await(finished),
|
|
139
|
+
Effect.raceFirst(notified, Effect.sleep(delay)),
|
|
140
|
+
);
|
|
141
|
+
}).pipe(Effect.repeat({ until: () => Deferred.isDone(finished) }));
|
|
142
|
+
}, Effect.scoped),
|
|
143
|
+
pendingDeadline: store
|
|
144
|
+
.nextDeadline()
|
|
145
|
+
.pipe(Effect.map(Option.fromNullishOr), Effect.mapError(failure("read message deadline"))),
|
|
146
|
+
});
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type AgentId } from "@effect-agent/core/Identifiers";
|
|
2
|
+
import { type DurableSubmitAgent } from "@effect-agent/thread/DurableAgentRuntime";
|
|
3
|
+
import { PreparedInputAdmission } from "@effect-agent/thread/PreparedInputAdmission";
|
|
4
|
+
import { PersistedJson } from "@effect-agent/thread/Records";
|
|
5
|
+
import {
|
|
6
|
+
type ScheduledEnvelope,
|
|
7
|
+
ScheduledInputAdmission,
|
|
8
|
+
ScheduledInputRetryable,
|
|
9
|
+
ScheduledInputRefused,
|
|
10
|
+
ScheduleStorageError,
|
|
11
|
+
} from "@effect-agent/thread/Schedule";
|
|
12
|
+
import { type PreparedInput } from "@effect-agent/thread/Subscription";
|
|
13
|
+
import { Effect, Layer } from "effect";
|
|
14
|
+
|
|
15
|
+
import { CloudflareThreadClient, type ThreadClientError } from "../CloudflareThreadClient.ts";
|
|
16
|
+
|
|
17
|
+
const passthroughAgent = (agentId: AgentId): DurableSubmitAgent<typeof PersistedJson> => ({
|
|
18
|
+
definition: { id: agentId, input: PersistedJson },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** Ordinary prepared admission through one freshly addressed Thread Object call. */
|
|
22
|
+
export const cloudflarePreparedInputAdmissionLayer: Layer.Layer<
|
|
23
|
+
PreparedInputAdmission,
|
|
24
|
+
never,
|
|
25
|
+
CloudflareThreadClient
|
|
26
|
+
> = Layer.effect(
|
|
27
|
+
PreparedInputAdmission,
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const client = yield* CloudflareThreadClient;
|
|
30
|
+
|
|
31
|
+
return PreparedInputAdmission.of({
|
|
32
|
+
submissionStatus: (receipt) =>
|
|
33
|
+
client
|
|
34
|
+
.submissionStatus(receipt)
|
|
35
|
+
.pipe(Effect.mapError(() => ScheduledInputRetryable.make({ reason: "storage" }))),
|
|
36
|
+
submit: (envelope) =>
|
|
37
|
+
client
|
|
38
|
+
.submit(passthroughAgent(envelope.agentId), envelope.input, {
|
|
39
|
+
threadId: envelope.threadId,
|
|
40
|
+
principal: envelope.deliveryPrincipal,
|
|
41
|
+
idempotencyKey: envelope.admissionKey,
|
|
42
|
+
...(envelope.admissionGroup === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { admissionGroup: envelope.admissionGroup }),
|
|
45
|
+
...(envelope.admissionFence === undefined
|
|
46
|
+
? {}
|
|
47
|
+
: { admissionFence: envelope.admissionFence }),
|
|
48
|
+
...(envelope.workerAdmission === undefined
|
|
49
|
+
? {}
|
|
50
|
+
: { workerAdmission: envelope.workerAdmission }),
|
|
51
|
+
...(envelope.messageAdmission === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: { messageAdmission: envelope.messageAdmission }),
|
|
54
|
+
definitions: envelope.definitions,
|
|
55
|
+
})
|
|
56
|
+
.pipe(
|
|
57
|
+
Effect.catchTags({
|
|
58
|
+
AdmissionConflict: () =>
|
|
59
|
+
ScheduleStorageError.make({ operation: "prepared admission", reason: "corrupt" }),
|
|
60
|
+
AdmissionLimitExceeded: () => ScheduledInputRetryable.make({ reason: "capacity" }),
|
|
61
|
+
ThreadClientError: (error: ThreadClientError) =>
|
|
62
|
+
ScheduledInputRetryable.make({
|
|
63
|
+
reason: error.overloaded === true ? "capacity" : "transport",
|
|
64
|
+
}),
|
|
65
|
+
HostProtocolError: () => ScheduledInputRetryable.make({ reason: "ambiguous" }),
|
|
66
|
+
AdmissionPolicyError: (error) =>
|
|
67
|
+
error.reason === "refused"
|
|
68
|
+
? ScheduledInputRefused.make({ code: error.code })
|
|
69
|
+
: ScheduledInputRetryable.make({
|
|
70
|
+
reason: error.reason === "occupied" ? "capacity" : "storage",
|
|
71
|
+
}),
|
|
72
|
+
LedgerError: () => ScheduledInputRetryable.make({ reason: "storage" }),
|
|
73
|
+
ThreadStoreError: () => ScheduledInputRetryable.make({ reason: "storage" }),
|
|
74
|
+
DurableAlarmError: () => ScheduledInputRetryable.make({ reason: "storage" }),
|
|
75
|
+
AgentInputError: () =>
|
|
76
|
+
ScheduleStorageError.make({ operation: "prepared admission", reason: "corrupt" }),
|
|
77
|
+
DigestError: () =>
|
|
78
|
+
ScheduleStorageError.make({ operation: "prepared admission", reason: "corrupt" }),
|
|
79
|
+
ThreadNotMaterialized: () => ScheduledInputRetryable.make({ reason: "storage" }),
|
|
80
|
+
AppendConflict: () => ScheduledInputRetryable.make({ reason: "ambiguous" }),
|
|
81
|
+
FenceRejected: () => ScheduledInputRetryable.make({ reason: "ambiguous" }),
|
|
82
|
+
DurableRuntimeFailpointError: () =>
|
|
83
|
+
ScheduledInputRetryable.make({ reason: "ambiguous" }),
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
});
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const preparedFromSchedule = (envelope: ScheduledEnvelope): PreparedInput => ({
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
threadId: envelope.threadId,
|
|
93
|
+
deliveryPrincipal: envelope.deliveryPrincipal,
|
|
94
|
+
...(envelope.admissionGroup === undefined ? {} : { admissionGroup: envelope.admissionGroup }),
|
|
95
|
+
...(envelope.admissionFence === undefined ? {} : { admissionFence: envelope.admissionFence }),
|
|
96
|
+
agentId: envelope.agentId,
|
|
97
|
+
definitions: envelope.definitions,
|
|
98
|
+
input: envelope.input,
|
|
99
|
+
inputDigest: envelope.inputDigest,
|
|
100
|
+
admissionKey: envelope.admissionKey,
|
|
101
|
+
authorization: envelope.authorization,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** Compatibility adapter retaining Scheduling's public admission port. */
|
|
105
|
+
export const cloudflareScheduledInputAdmissionLayer: Layer.Layer<
|
|
106
|
+
ScheduledInputAdmission,
|
|
107
|
+
never,
|
|
108
|
+
PreparedInputAdmission
|
|
109
|
+
> = Layer.effect(
|
|
110
|
+
ScheduledInputAdmission,
|
|
111
|
+
Effect.map(PreparedInputAdmission, (admission) =>
|
|
112
|
+
ScheduledInputAdmission.of({
|
|
113
|
+
submit: (envelope) => admission.submit(preparedFromSchedule(envelope)),
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Context, Deferred, Effect, Layer, Ref, type Scope } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Cancellation tombstones are bounded hints, never durable authority. */
|
|
4
|
+
const MAX_CANCELLATION_TOMBSTONES = 1_024;
|
|
5
|
+
|
|
6
|
+
type ActiveRegistration = ReadonlySet<Deferred.Deferred<void>>;
|
|
7
|
+
type Registration = ActiveRegistration | "cancelled";
|
|
8
|
+
type Registrations = ReadonlyMap<string, Registration>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Per-incarnation cancellation registry for long-lived progress RPCs. The public runtime owns
|
|
12
|
+
* the actual wake registration; this host-only registry lets an interrupted Worker Effect ask
|
|
13
|
+
* the Object to interrupt its scoped wait before the Worker execution context itself ends.
|
|
14
|
+
*/
|
|
15
|
+
export class ProgressWaitRegistry extends Context.Service<
|
|
16
|
+
ProgressWaitRegistry,
|
|
17
|
+
{
|
|
18
|
+
/** Register a Scope-owned cancellation signal, observing any early cancel tombstone. */
|
|
19
|
+
readonly subscribe: (
|
|
20
|
+
waiterId: string,
|
|
21
|
+
) => Effect.Effect<Effect.Effect<void>, never, Scope.Scope>;
|
|
22
|
+
/** Cancel every attempt and retain a bounded tombstone for later transport attempts. */
|
|
23
|
+
readonly cancel: (waiterId: string) => Effect.Effect<void>;
|
|
24
|
+
}
|
|
25
|
+
>()("@effect-agent/platform-cloudflare/ProgressWaitRegistry") {
|
|
26
|
+
static readonly layer: Layer.Layer<ProgressWaitRegistry> = Layer.effect(
|
|
27
|
+
ProgressWaitRegistry,
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const registrations = yield* Ref.make<Registrations>(new Map());
|
|
30
|
+
|
|
31
|
+
const remove = (waiterId: string, deferred: Deferred.Deferred<void>) =>
|
|
32
|
+
Ref.update(registrations, (current) => {
|
|
33
|
+
const existing = current.get(waiterId);
|
|
34
|
+
|
|
35
|
+
if (existing === undefined || existing === "cancelled" || !existing.has(deferred)) {
|
|
36
|
+
return current;
|
|
37
|
+
}
|
|
38
|
+
const next = new Map(current);
|
|
39
|
+
const active = new Set(existing);
|
|
40
|
+
|
|
41
|
+
active.delete(deferred);
|
|
42
|
+
if (active.size === 0) {
|
|
43
|
+
next.delete(waiterId);
|
|
44
|
+
} else {
|
|
45
|
+
next.set(waiterId, active);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return next;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const subscribe = Effect.fn("ProgressWaitRegistry.subscribe")(
|
|
52
|
+
(waiterId: string): Effect.Effect<Effect.Effect<void>, never, Scope.Scope> =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
const deferred = yield* Deferred.make<void>();
|
|
55
|
+
|
|
56
|
+
yield* Effect.addFinalizer(() => remove(waiterId, deferred));
|
|
57
|
+
|
|
58
|
+
const cancelled = yield* Ref.modify(registrations, (current) => {
|
|
59
|
+
const existing = current.get(waiterId);
|
|
60
|
+
const next = new Map(current);
|
|
61
|
+
|
|
62
|
+
if (existing === "cancelled") {
|
|
63
|
+
return [true, current] as const;
|
|
64
|
+
}
|
|
65
|
+
const active = new Set(existing ?? []);
|
|
66
|
+
|
|
67
|
+
active.add(deferred);
|
|
68
|
+
next.set(waiterId, active);
|
|
69
|
+
|
|
70
|
+
return [false, next] as const;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return { cancelled, deferred };
|
|
74
|
+
}).pipe(
|
|
75
|
+
Effect.map(({ cancelled, deferred }) =>
|
|
76
|
+
cancelled ? Effect.void : Deferred.await(deferred),
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Updating the registry and completing captured signals form one nonblocking operation;
|
|
82
|
+
// interruption between them must not strand attempts removed from the active registry.
|
|
83
|
+
const cancel = Effect.fn("ProgressWaitRegistry.cancel")(function* (waiterId: string) {
|
|
84
|
+
const waiters = yield* Ref.modify(
|
|
85
|
+
registrations,
|
|
86
|
+
(current): readonly [ReadonlyArray<Deferred.Deferred<void>>, Registrations] => {
|
|
87
|
+
const existing = current.get(waiterId);
|
|
88
|
+
|
|
89
|
+
if (existing === "cancelled") return [[], current] as const;
|
|
90
|
+
const next = new Map(current);
|
|
91
|
+
|
|
92
|
+
// A retry may arrive after an active attempt was cancelled. Retain the same
|
|
93
|
+
// tombstone used for early cancellation, ordered by cancellation time.
|
|
94
|
+
next.delete(waiterId);
|
|
95
|
+
next.set(waiterId, "cancelled");
|
|
96
|
+
let tombstones = 0;
|
|
97
|
+
|
|
98
|
+
for (const registration of next.values()) {
|
|
99
|
+
if (registration === "cancelled") tombstones += 1;
|
|
100
|
+
}
|
|
101
|
+
if (tombstones > MAX_CANCELLATION_TOMBSTONES) {
|
|
102
|
+
for (const [id, registration] of next) {
|
|
103
|
+
if (registration !== "cancelled") continue;
|
|
104
|
+
next.delete(id);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return [existing === undefined ? [] : [...existing], next] as const;
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
yield* Effect.forEach(waiters, (waiter) => Deferred.succeed(waiter, undefined), {
|
|
114
|
+
discard: true,
|
|
115
|
+
});
|
|
116
|
+
}, Effect.uninterruptible);
|
|
117
|
+
|
|
118
|
+
return ProgressWaitRegistry.of({ subscribe, cancel });
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ThreadPortTransport,
|
|
3
|
+
portTransportFailure,
|
|
4
|
+
} from "@effect-agent/storage-cloudflare/PortRouting";
|
|
5
|
+
import { Effect, Layer } from "effect";
|
|
6
|
+
|
|
7
|
+
import { ThreadObjectNamespace } from "../CloudflareBindings.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `ThreadPortTransport` over native Durable Object JS RPC (decision D-P6-3): one
|
|
11
|
+
* `portCall(envelope)` on the stub of the Object that owns the addressed Thread
|
|
12
|
+
* (`namespace.idFromName(threadId)` — the identity rule, plan §1.2). The envelopes are
|
|
13
|
+
* already Schema-encoded JSON, so the RPC boundary carries only structured-cloneable values;
|
|
14
|
+
* the protocol module stays transport-agnostic and fetch-with-JSON remains the documented
|
|
15
|
+
* fallback carrier.
|
|
16
|
+
*
|
|
17
|
+
* Every delivery problem — stub construction, RPC rejection, overload, deploy-in-progress —
|
|
18
|
+
* surfaces as `PortTransportError` (preserving the platform stub's own `retryable` signal
|
|
19
|
+
* when present) and NEVER as a fabricated answer: on `resolveAdmission` the routing layer
|
|
20
|
+
* turns exactly this error into `AdmissionIndeterminate` (SUB-031).
|
|
21
|
+
*/
|
|
22
|
+
export const threadPortTransportLayer: Layer.Layer<
|
|
23
|
+
ThreadPortTransport,
|
|
24
|
+
never,
|
|
25
|
+
ThreadObjectNamespace
|
|
26
|
+
> = Layer.effect(ThreadPortTransport)(
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const { get } = yield* ThreadObjectNamespace;
|
|
29
|
+
|
|
30
|
+
return ThreadPortTransport.of({
|
|
31
|
+
call: (threadId, request) =>
|
|
32
|
+
Effect.tryPromise({
|
|
33
|
+
try: () => get(threadId).portCall(request),
|
|
34
|
+
catch: (cause) => portTransportFailure(threadId, cause),
|
|
35
|
+
}).pipe(
|
|
36
|
+
Effect.withSpan("CloudflarePortTransport.call", {
|
|
37
|
+
attributes: { threadId },
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
});
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import puppeteer, { type Browser } from "@cloudflare/puppeteer";
|
|
3
|
+
import { type InteractiveBrowserPolicy } from "@effect-agent/sandbox/InteractiveBrowser";
|
|
4
|
+
import { ProtectedBrowserError } from "@effect-agent/sandbox/ProtectedBrowser";
|
|
5
|
+
import { Clock, Context, Crypto, Effect, Layer, Redacted, Schema, type Scope } from "effect";
|
|
6
|
+
|
|
7
|
+
import { BrowserRunSessionLifecycle } from "../internal/browser-session-lifecycle.ts";
|
|
8
|
+
import { makeProtectedNativeTransport, ProtectedNativeSession } from "./native.ts";
|
|
9
|
+
import {
|
|
10
|
+
BrowserRunProtectedTransport,
|
|
11
|
+
ProtectedTransportError,
|
|
12
|
+
type ProtectedBrowserTransport,
|
|
13
|
+
} from "./policy.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Acquires through BROWSER with recording=false explicitly on the wire. The trusted provider's
|
|
17
|
+
* documented opt-in semantics are the recording guarantee; no recording-status API exists.
|
|
18
|
+
* Account operators remain trusted, and must not attach external observers to private passes.
|
|
19
|
+
*/
|
|
20
|
+
export const browserRunProtectedBindingLayer = (options: {
|
|
21
|
+
readonly browser: Pick<BrowserRun, "fetch">;
|
|
22
|
+
}) =>
|
|
23
|
+
Layer.effect(BrowserRunProtectedTransport)(
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const lifecycle = yield* BrowserRunSessionLifecycle;
|
|
26
|
+
const crypto = yield* Crypto.Crypto;
|
|
27
|
+
|
|
28
|
+
const open = Effect.fn("BrowserRunProtectedTransport.open")(function* (
|
|
29
|
+
policy: InteractiveBrowserPolicy,
|
|
30
|
+
): Effect.fn.Return<ProtectedBrowserTransport, ProtectedBrowserError, Scope.Scope> {
|
|
31
|
+
const failure = () =>
|
|
32
|
+
new ProtectedBrowserError({
|
|
33
|
+
reason: "provider",
|
|
34
|
+
dispatch: "not-dispatched",
|
|
35
|
+
milestone: "none",
|
|
36
|
+
observation: "closed",
|
|
37
|
+
cleanup: "unconfirmed",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let sessionId: Redacted.Redacted<string> | undefined;
|
|
41
|
+
let browser: Browser | undefined;
|
|
42
|
+
let driver: ProtectedBrowserTransport | undefined;
|
|
43
|
+
let invalid = false;
|
|
44
|
+
// SDK acquisition may finish after interruption. Its late-reply callback must await cleanup,
|
|
45
|
+
// using this pass's clock rather than starting an Effect runtime with default services.
|
|
46
|
+
const runCleanup = Effect.runPromiseWith(Context.make(Clock.Clock, yield* Clock.Clock));
|
|
47
|
+
|
|
48
|
+
const terminate = Effect.gen(function* () {
|
|
49
|
+
invalid = true;
|
|
50
|
+
driver?.invalidate();
|
|
51
|
+
if (sessionId === undefined) return "unconfirmed" as const;
|
|
52
|
+
|
|
53
|
+
const cleanup = yield* lifecycle.close(sessionId).pipe(
|
|
54
|
+
Effect.as("confirmed" as const),
|
|
55
|
+
Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
|
|
56
|
+
Effect.interruptible,
|
|
57
|
+
Effect.timeoutOrElse({
|
|
58
|
+
duration: "10 seconds",
|
|
59
|
+
orElse: () => Effect.succeed("unconfirmed" as const),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Local disconnect is not remote-closure evidence. Do it even when confirmation fails.
|
|
64
|
+
const connected = browser;
|
|
65
|
+
|
|
66
|
+
if (connected !== undefined)
|
|
67
|
+
yield* Effect.promise(() => connected.disconnect()).pipe(
|
|
68
|
+
Effect.catchCause(() => Effect.void),
|
|
69
|
+
Effect.interruptible,
|
|
70
|
+
Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.void }),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return cleanup;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const close = yield* Effect.cached(terminate);
|
|
77
|
+
|
|
78
|
+
yield* Effect.addFinalizer(() =>
|
|
79
|
+
close.pipe(
|
|
80
|
+
Effect.flatMap((state) =>
|
|
81
|
+
state === "confirmed"
|
|
82
|
+
? Effect.void
|
|
83
|
+
: Effect.logWarning("Protected browser exact-session closure unconfirmed"),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const acquired = yield* Effect.tryPromise({
|
|
89
|
+
try: async (signal) => {
|
|
90
|
+
let recordingDisabled = false;
|
|
91
|
+
|
|
92
|
+
const binding = {
|
|
93
|
+
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
94
|
+
const request = new Request(input, init);
|
|
95
|
+
const url = new URL(request.url);
|
|
96
|
+
|
|
97
|
+
if (url.pathname === "/v1/devtools/browser" && request.method === "POST") {
|
|
98
|
+
url.searchParams.set("recording", "false");
|
|
99
|
+
recordingDisabled = url.searchParams.get("recording") === "false";
|
|
100
|
+
|
|
101
|
+
return options.browser.fetch(new Request(url, request));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return options.browser.fetch(request);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const acquired = await puppeteer.acquire(binding, {
|
|
109
|
+
recording: false,
|
|
110
|
+
// Provider inactivity timeout, independent of the finite total pass deadline.
|
|
111
|
+
keep_alive: Math.min(600_000, Math.max(10_000, policy.maxElapsedMillis)),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
sessionId = Redacted.make(
|
|
115
|
+
Schema.decodeUnknownSync(Schema.String.check(Schema.isUUID()))(acquired.sessionId),
|
|
116
|
+
);
|
|
117
|
+
if (!recordingDisabled || signal.aborted || invalid) {
|
|
118
|
+
await runCleanup(terminate);
|
|
119
|
+
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
120
|
+
}
|
|
121
|
+
// Initial attachment only. No recovery/reconnection path exists.
|
|
122
|
+
browser = await puppeteer.connect(options.browser, acquired.sessionId);
|
|
123
|
+
if (signal.aborted || invalid) {
|
|
124
|
+
await runCleanup(terminate);
|
|
125
|
+
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
126
|
+
}
|
|
127
|
+
const context = await browser.createBrowserContext();
|
|
128
|
+
const page = await context.newPage();
|
|
129
|
+
|
|
130
|
+
await page.setBypassServiceWorker(true);
|
|
131
|
+
await page.setRequestInterception(true);
|
|
132
|
+
page.on("request", (request) => {
|
|
133
|
+
let allowed = policy.network._tag === "Unrestricted";
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const url = new URL(request.url());
|
|
137
|
+
|
|
138
|
+
allowed ||=
|
|
139
|
+
policy.network._tag === "ExactHosts" &&
|
|
140
|
+
url.protocol === "https:" &&
|
|
141
|
+
!url.username &&
|
|
142
|
+
!url.password &&
|
|
143
|
+
policy.network.allowedHosts.includes(url.host);
|
|
144
|
+
} catch {
|
|
145
|
+
/* Refuse malformed destinations. */
|
|
146
|
+
}
|
|
147
|
+
void (
|
|
148
|
+
allowed && !invalid ? request.continue() : request.abort("blockedbyclient")
|
|
149
|
+
).catch(() => {
|
|
150
|
+
invalid = true;
|
|
151
|
+
driver?.invalidate();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
if (signal.aborted || invalid) {
|
|
155
|
+
await runCleanup(terminate);
|
|
156
|
+
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return ProtectedNativeSession.of({ browser, page, close });
|
|
160
|
+
},
|
|
161
|
+
catch: failure,
|
|
162
|
+
}).pipe(
|
|
163
|
+
Effect.timeoutOrElse({
|
|
164
|
+
duration: Math.min(policy.maxElapsedMillis, 30_000),
|
|
165
|
+
orElse: () =>
|
|
166
|
+
Effect.fail(new ProtectedBrowserError({ ...failure(), reason: "timeout" })),
|
|
167
|
+
}),
|
|
168
|
+
Effect.catch((error) =>
|
|
169
|
+
close.pipe(
|
|
170
|
+
Effect.flatMap((cleanup) =>
|
|
171
|
+
Effect.fail(new ProtectedBrowserError({ ...error, cleanup })),
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
driver = yield* makeProtectedNativeTransport(policy).pipe(
|
|
178
|
+
Effect.provideService(ProtectedNativeSession, acquired),
|
|
179
|
+
Effect.provideService(Crypto.Crypto, crypto),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return driver;
|
|
183
|
+
}, Effect.withTracerEnabled(false));
|
|
184
|
+
|
|
185
|
+
return { open };
|
|
186
|
+
}),
|
|
187
|
+
);
|