tardie 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tardie",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "author": "Clavia, Inc.",
6
6
  "description": "A durable agent harness. State is a pure function of the log.",
@@ -16,7 +16,7 @@
16
16
  "access": "public"
17
17
  },
18
18
  "tardigrade": {
19
- "sourceTree": "8db5a667a08511b951c0a6e341f585b76e77fb65"
19
+ "sourceTree": "97448ddae8564fb78c339ce7cc9423dfe7e18b73"
20
20
  },
21
21
  "files": [
22
22
  "src",
@@ -35,6 +35,10 @@ export type CloudflareHostOptions<R> = {
35
35
  readonly pick?: (dirty: ReadonlySet<string>) => string
36
36
  readonly keyOf?: (event: Event) => string | undefined
37
37
  } & LayersFor<R>
38
+ export interface CloudflareHostRouting {
39
+ readonly localThread: string | undefined
40
+ }
41
+
38
42
 
39
43
  export interface CloudflareHost {
40
44
  readonly read: (lane: string) => Promise<ReadonlyArray<Event>>
@@ -59,7 +63,22 @@ const laneOf = (address: string): string => {
59
63
  }
60
64
 
61
65
  // createCloudflareHost binds one actor graph to Effect SQL over its Durable Object storage.
62
- export const createCloudflareHost = async <R = never>(options: CloudflareHostOptions<R>): Promise<CloudflareHost> => {
66
+ export function createCloudflareHost<R = never>(
67
+ options: CloudflareHostOptions<R>
68
+ ): Promise<CloudflareHost>
69
+ export function createCloudflareHost<R = never>(
70
+ routing: CloudflareHostRouting,
71
+ options: CloudflareHostOptions<R>
72
+ ): Promise<CloudflareHost>
73
+ export async function createCloudflareHost<R = never>(
74
+ routingOrOptions: CloudflareHostRouting | CloudflareHostOptions<R>,
75
+ providedOptions?: CloudflareHostOptions<R>
76
+ ): Promise<CloudflareHost> {
77
+ const options = "storage" in routingOrOptions ? routingOrOptions : providedOptions
78
+ if (options === undefined) throw new Error("Cloudflare host options are required")
79
+ const routing = "storage" in routingOrOptions
80
+ ? { localThread: undefined }
81
+ : routingOrOptions
63
82
  const database = ManagedRuntime.make(SqliteClient.layer({ storage: options.storage }))
64
83
  const sql = await database.runPromise(SqliteClient.SqliteClient)
65
84
  const workspaceRuntime = ManagedRuntime.make(layerWorkspace(sql))
@@ -133,7 +152,16 @@ export const createCloudflareHost = async <R = never>(options: CloudflareHostOpt
133
152
  send: (_destination, envelope) => commitEffect(envelope.link.target, envelope.event, envelope.lineage, envelope.link, envelope.call)
134
153
  }
135
154
  const routes = [
136
- directoryRoute(localTransport, mappedDirectory((id: ActorId) => id.actor === options.principal ? id : undefined), isActorEnvelope, (envelope) => envelope.link.target),
155
+ directoryRoute(
156
+ localTransport,
157
+ mappedDirectory((id: ActorId) =>
158
+ id.actor === options.principal && (routing.localThread === undefined || id.thread === routing.localThread)
159
+ ? id
160
+ : undefined
161
+ ),
162
+ isActorEnvelope,
163
+ (envelope) => envelope.link.target
164
+ ),
137
165
  directoryRoute(providerTransport, mappedDirectory<ProviderEndpoint, ProviderEndpoint>((endpoint) => endpoint), isProviderEnvelope, (envelope) => envelope.link.target),
138
166
  ...(options.routes ?? [])
139
167
  ]
@@ -70,10 +70,15 @@ export interface Env {
70
70
 
71
71
  const LANE_PREFIX = "ag."
72
72
  const laneOf = (thread: string): string => `${LANE_PREFIX}${thread}`
73
+ const actorThreadOf = (thread: string): string => thread.startsWith(LANE_PREFIX) ? thread : laneOf(thread)
73
74
  const threadOf = (lane: string): string | undefined => lane.startsWith(LANE_PREFIX) ? lane.slice(LANE_PREFIX.length) : undefined
74
75
 
75
76
  const flattenThreads = (nodes: ReadonlyArray<ThreadNode>): ReadonlyArray<ThreadSummary> =>
76
77
  nodes.flatMap(({ children, ...summary }) => [summary, ...flattenThreads(children)])
78
+ export type CloudflarePlacement = "actor" | "thread"
79
+
80
+ const objectNameOf = (actor: string, thread: string | undefined, placement: CloudflarePlacement): string =>
81
+ placement === "thread" && thread !== undefined ? JSON.stringify([actor, thread]) : actor
77
82
 
78
83
  const DEFAULT_ACTOR_NAME = "default"
79
84
 
@@ -86,6 +91,7 @@ interface MountedActor {
86
91
  readonly modelAdapters: ModelAdapterRegistry
87
92
  readonly inferenceObserverFor?: (context: CloudflareWorkerLayerContext<Env>) => InferenceObserver
88
93
  readonly layersFor?: (context: CloudflareWorkerLayerContext<Env>) => CloudflareLaneEnv<never>
94
+ readonly placement: CloudflarePlacement
89
95
  }
90
96
 
91
97
  let mountedActor: MountedActor | undefined
@@ -348,6 +354,7 @@ export class ActorHost extends DurableObject<Env> {
348
354
  private catalogState: Promise<ModelCatalogState> | undefined
349
355
  private driving: Promise<void> | undefined
350
356
  private principal: string | undefined
357
+ private threadId: string | undefined
351
358
  private readonly alarmPolicy: AlarmPolicy
352
359
  private readonly sandboxCalls = new Map<
353
360
  string,
@@ -362,19 +369,35 @@ export class ActorHost extends DurableObject<Env> {
362
369
  : { recoveryDelayMillis: nonNegativeInteger(env.TARDIGRADE_ALARM_DELAY_MILLIS, 0, "TARDIGRADE_ALARM_DELAY_MILLIS") })
363
370
  }
364
371
 
365
- async init(name: string): Promise<void> {
372
+ async init(name: string, thread?: string): Promise<void> {
366
373
  if (!deployed(name)) throw new Error(`actor ${JSON.stringify(name)} is not deployed`)
367
374
  this.ctx.storage.sql.exec("INSERT OR IGNORE INTO actor_meta (key, value) VALUES ('principal', ?)", name)
368
- const principal = this.ctx.storage.sql.exec<{ value: string }>("SELECT value FROM actor_meta WHERE key = 'principal'").one().value
369
- if (principal !== name) throw new Error("actor host name does not match its durable identity")
375
+ if (thread !== undefined) {
376
+ this.ctx.storage.sql.exec("INSERT OR IGNORE INTO actor_meta (key, value) VALUES ('thread', ?)", thread)
377
+ }
378
+ const principal = this.meta("principal")
379
+ const storedThread = this.meta("thread", false)
380
+ if (principal !== name) throw new Error("actor definition does not match the durable host identity")
381
+ if (storedThread !== thread) throw new Error("actor thread does not match the durable host identity")
370
382
  this.principal ??= principal
383
+ this.threadId ??= storedThread
384
+ }
385
+
386
+ private meta(key: string, required = true): string | undefined {
387
+ const row = this.ctx.storage.sql.exec<{ value: string }>(
388
+ "SELECT value FROM actor_meta WHERE key = ?",
389
+ key
390
+ ).toArray()[0]
391
+ if (row === undefined && required) throw new Error("actor host has not been initialized")
392
+ return row?.value
371
393
  }
372
394
 
373
395
  private name(): string {
374
- if (this.principal !== undefined) return this.principal
375
- const row = this.ctx.storage.sql.exec<{ value: string }>("SELECT value FROM actor_meta WHERE key = 'principal'").toArray()[0]
376
- if (row === undefined) throw new Error("actor host has not been initialized")
377
- return this.principal = row.value
396
+ return this.principal ??= this.meta("principal")!
397
+ }
398
+
399
+ private thread(): string | undefined {
400
+ return this.threadId ??= this.meta("thread", false)
378
401
  }
379
402
 
380
403
  async catalog(): Promise<ModelCatalogState> {
@@ -415,6 +438,11 @@ export class ActorHost extends DurableObject<Env> {
415
438
  const principal = this.name()
416
439
  const selectedAssembly = assemblyOf(principal, this.env, models, catalog)
417
440
  if (selectedAssembly === undefined) throw new Error(`actor ${JSON.stringify(principal)} is not deployed`)
441
+ const placement = mountedActor?.placement ?? "actor"
442
+ const currentThread = this.thread()
443
+ if (placement === "thread" && currentThread === undefined) {
444
+ throw new Error("thread-scoped actor host requires a thread identity")
445
+ }
418
446
  const sandboxCpuMs = optionalNonNegativeInteger(this.env.TARDIGRADE_SANDBOX_CPU_MILLIS, "TARDIGRADE_SANDBOX_CPU_MILLIS")
419
447
  const sandboxSubRequests = optionalNonNegativeInteger(
420
448
  this.env.TARDIGRADE_SANDBOX_SUBREQUESTS,
@@ -457,8 +485,9 @@ export class ActorHost extends DurableObject<Env> {
457
485
  : envelope.event
458
486
  return Effect.promise(async () => {
459
487
  if (!deployed(destination.actor)) throw new Error(`actor ${JSON.stringify(destination.actor)} is not deployed`)
460
- const stub = this.env.ACTORS.getByName(destination.actor)
461
- await stub.init(destination.actor)
488
+ const targetThread = placement === "thread" ? destination.thread : undefined
489
+ const stub = this.env.ACTORS.getByName(objectNameOf(destination.actor, targetThread, placement))
490
+ await stub.init(destination.actor, targetThread)
462
491
  await stub.deliver({ ...envelope, event })
463
492
  })
464
493
  })
@@ -466,11 +495,16 @@ export class ActorHost extends DurableObject<Env> {
466
495
  }
467
496
  const remoteRoute = directoryRoute(
468
497
  remoteTransport,
469
- mappedDirectory((id: ActorId) => id.actor === principal ? undefined : id),
498
+ mappedDirectory((id: ActorId) => {
499
+ if (placement === "actor") return id.actor === principal ? undefined : id
500
+ return id.actor === principal && id.thread === currentThread ? undefined : id
501
+ }),
470
502
  isActorEnvelope,
471
503
  (envelope) => envelope.link.target
472
504
  )
473
- return createCloudflareHost({
505
+ return createCloudflareHost(
506
+ { localThread: placement === "thread" ? currentThread : undefined },
507
+ {
474
508
  storage: this.ctx.storage,
475
509
  principal,
476
510
  actorFor: (lane) => threadOf(lane) === undefined ? undefined : selectedAssembly,
@@ -489,7 +523,8 @@ export class ActorHost extends DurableObject<Env> {
489
523
  )
490
524
  }),
491
525
  keyOf: selectedAssembly.keyOf
492
- })
526
+ }
527
+ )
493
528
  }
494
529
 
495
530
  private host(): Promise<CloudflareHost> {
@@ -559,19 +594,33 @@ export class ActorHost extends DurableObject<Env> {
559
594
  }
560
595
 
561
596
  async append(thread: string, event: Event): Promise<void> {
597
+ const actorThread = actorThreadOf(thread)
598
+ const ownedThread = this.thread()
599
+ if (ownedThread !== undefined && ownedThread !== actorThread) {
600
+ throw new Error("request thread does not match actor host identity")
601
+ }
562
602
  const stamped = event.at === undefined ? { ...event, at: Date.now() } : event
563
603
  const host = await this.host()
564
- await this.accept(host, () => host.stageRoot(host.self(laneOf(thread)), stamped))
604
+ await this.accept(host, () => host.stageRoot(host.self(actorThread), stamped))
565
605
  }
566
606
 
567
607
  async deliver(envelope: ActorEnvelope): Promise<void> {
568
- if (envelope.link.target.actor !== this.name()) throw new Error("delivery target does not match actor host")
608
+ if (envelope.link.target.actor !== this.name()) throw new Error("delivery target does not match actor definition")
609
+ const ownedThread = this.thread()
610
+ if (ownedThread !== undefined && envelope.link.target.thread !== ownedThread) {
611
+ throw new Error("delivery target does not match actor thread")
612
+ }
569
613
  const host = await this.host()
570
614
  await this.accept(host, () => host.stage(envelope))
571
615
  }
572
616
 
573
617
  async events(thread: string): Promise<ReadonlyArray<Event>> {
574
- return (await this.host()).read(laneOf(thread))
618
+ const actorThread = actorThreadOf(thread)
619
+ const ownedThread = this.thread()
620
+ if (ownedThread !== undefined && ownedThread !== actorThread) {
621
+ throw new Error("request thread does not match actor host identity")
622
+ }
623
+ return (await this.host()).read(actorThread)
575
624
  }
576
625
 
577
626
  async threads(): Promise<ReadonlyArray<ThreadSummary>> {
@@ -602,11 +651,14 @@ export class ActorHost extends DurableObject<Env> {
602
651
 
603
652
  const actorStub = async (
604
653
  env: Env,
605
- name: string
654
+ name: string,
655
+ thread?: string
606
656
  ): Promise<DurableObjectStub<ActorHost> | undefined> => {
607
657
  if (!deployed(name)) return undefined
608
- const stub = env.ACTORS.getByName(name)
609
- await stub.init(name)
658
+ const placement = mountedActor?.placement ?? "actor"
659
+ const targetThread = placement === "thread" && thread !== undefined ? actorThreadOf(thread) : undefined
660
+ const stub = env.ACTORS.getByName(objectNameOf(name, targetThread, placement))
661
+ await stub.init(name, targetThread)
610
662
  return stub
611
663
  }
612
664
 
@@ -677,6 +729,9 @@ const protectedRoute = <E, R>(
677
729
 
678
730
  const routes = [
679
731
  HttpRouter.route("GET", "/healthz", Effect.gen(function* () {
732
+ if ((mountedActor?.placement ?? "actor") === "thread") {
733
+ return json({ status: "ready", actor: deployedActor })
734
+ }
680
735
  const env = yield* WorkerEnv
681
736
  return json(yield* Effect.promise(async () => (await actorStub(env, deployedActor))!.status()))
682
737
  })),
@@ -756,7 +811,7 @@ const routes = [
756
811
  const at = yield* Clock.currentTimeMillis
757
812
  const decoded = methodEventOf(method, { id: call, input, at })
758
813
  if ("error" in decoded) return json({ error: decoded.error }, 400)
759
- const stub = yield* Effect.promise(() => actorStub(env, actor))
814
+ const stub = yield* Effect.promise(() => actorStub(env, actor, thread))
760
815
  if (stub === undefined) return json({ error: "actor is not deployed" }, 503)
761
816
  yield* Effect.promise(() => stub.append(thread, decoded.event))
762
817
  return json({ thread, method: methodName, call }, 202)
@@ -771,7 +826,7 @@ const routes = [
771
826
  const call = decodeURIComponent(params.call ?? "")
772
827
  const method = methodsOf(actor)?.[methodName]
773
828
  if (method === undefined) return json({ error: "unknown method" }, 404)
774
- const stub = yield* Effect.promise(() => actorStub(env, actor))
829
+ const stub = yield* Effect.promise(() => actorStub(env, actor, thread))
775
830
  if (stub === undefined) return json({ error: "actor is not deployed" }, 503)
776
831
  const events = yield* Effect.promise(() => stub.events(thread)).pipe(
777
832
  Effect.map((value) => value as ReadonlyArray<Event>)
@@ -782,6 +837,9 @@ const routes = [
782
837
  )),
783
838
  HttpRouter.route("GET", "/v1/threads", protectedRoute((_request, env) =>
784
839
  Effect.gen(function* () {
840
+ if ((mountedActor?.placement ?? "actor") === "thread") {
841
+ return json({ error: "thread-scoped workers do not support actor-wide thread listing" }, 400)
842
+ }
785
843
  const stub = yield* Effect.promise(() => actorStub(env, deployedActor))
786
844
  if (stub === undefined) return json({ error: "actor is not deployed" }, 503)
787
845
  return json(yield* Effect.promise(() => stub.threads()))
@@ -792,7 +850,7 @@ const routes = [
792
850
  const params = yield* HttpRouter.params
793
851
  const actor = deployedActor
794
852
  const thread = decodeURIComponent(params.thread ?? "")
795
- const stub = yield* Effect.promise(() => actorStub(env, actor))
853
+ const stub = yield* Effect.promise(() => actorStub(env, actor, thread))
796
854
  if (stub === undefined) return json({ error: "actor is not deployed" }, 503)
797
855
  const event = (yield* request.json.pipe(Effect.orElseSucceed(() => undefined))) as Event | undefined
798
856
  if (typeof event !== "object" || event === null || typeof event.type !== "string" || event.type === "") {
@@ -807,7 +865,7 @@ const routes = [
807
865
  const params = yield* HttpRouter.params
808
866
  const actor = deployedActor
809
867
  const thread = decodeURIComponent(params.thread ?? "")
810
- const stub = yield* Effect.promise(() => actorStub(env, actor))
868
+ const stub = yield* Effect.promise(() => actorStub(env, actor, thread))
811
869
  if (stub === undefined) return json({ error: "actor is not deployed" }, 503)
812
870
  const url = new URL(request.url, "http://worker")
813
871
  const after = Number(url.searchParams.get("after") ?? 0)
@@ -856,10 +914,14 @@ export interface CloudflareWorkerLayerContext<WorkerEnv extends Env = Env> {
856
914
  type CloudflareWorkerLayersFor<R, WorkerEnv extends Env> = (
857
915
  context: CloudflareWorkerLayerContext<WorkerEnv>
858
916
  ) => CloudflareLaneEnv<CloudflareApplicationRequirements<R>>
917
+ interface CloudflareWorkerPlacementOptions {
918
+ readonly placement?: CloudflarePlacement
919
+ }
920
+
859
921
 
860
922
  // CloudflareWorkerOptions supplies every actor requirement the Worker does not bind itself.
861
923
  export type CloudflareWorkerOptions<R, WorkerEnv extends Env = Env> =
862
- [CloudflareApplicationRequirements<R>] extends [never]
924
+ ([CloudflareApplicationRequirements<R>] extends [never]
863
925
  ? {
864
926
  readonly layersFor?: CloudflareWorkerLayersFor<R, WorkerEnv>
865
927
  readonly modelAdapters?: ModelAdapterRegistry
@@ -869,7 +931,8 @@ export type CloudflareWorkerOptions<R, WorkerEnv extends Env = Env> =
869
931
  readonly layersFor: CloudflareWorkerLayersFor<R, WorkerEnv>
870
932
  readonly modelAdapters?: ModelAdapterRegistry
871
933
  readonly inferenceObserverFor?: (context: CloudflareWorkerLayerContext<WorkerEnv>) => InferenceObserver
872
- }
934
+ }) &
935
+ CloudflareWorkerPlacementOptions
873
936
 
874
937
  type CloudflareWorkerArguments<R, WorkerEnv extends Env> =
875
938
  [CloudflareApplicationRequirements<R>] extends [never]
@@ -890,6 +953,7 @@ export const cloudflareWorker = <
890
953
  actor: definition as unknown as DefaultAssembly,
891
954
  methods: definition.methods,
892
955
  modelAdapters: options?.modelAdapters ?? modelAdapters(),
956
+ placement: options?.placement ?? "actor",
893
957
  ...(options?.inferenceObserverFor === undefined ? {} : {
894
958
  inferenceObserverFor: options.inferenceObserverFor as unknown as (context: CloudflareWorkerLayerContext<Env>) => InferenceObserver
895
959
  }),