| ... | ... | @@ -349,6 +349,157 @@ it.effect("memory recovery selection includes unfinished items from missing runs |
| 349 | 349 | }).pipe(Effect.provide(ProjectionStore.layerMemory)), |
| 350 | 350 | ); |
| 351 | 351 | |
| 352 | // Project A holds an active thread, an archived thread and a fork of a run in |
| 353 | // an archived project B thread, so a project-scoped snapshot still has to load |
| 354 | // a fork source that is outside the project and archived. |
| 355 | const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(function* ( |
| 356 | suffix: string, |
| 357 | ) { |
| 358 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; |
| 359 | const now = yield* DateTime.now; |
| 360 | const projectId = ProjectId.make(`project:${suffix}:a`); |
| 361 | const otherProjectId = ProjectId.make(`project:${suffix}:b`); |
| 362 | const activeThreadId = ThreadId.make(`thread:${suffix}:a-active`); |
| 363 | const archivedThreadId = ThreadId.make(`thread:${suffix}:a-archived`); |
| 364 | const forkThreadId = ThreadId.make(`thread:${suffix}:a-fork`); |
| 365 | const sourceThreadId = ThreadId.make(`thread:${suffix}:b-source`); |
| 366 | const otherThreadId = ThreadId.make(`thread:${suffix}:b-other`); |
| 367 | const sourceRunId = RunId.make(`run:${suffix}:b-source`); |
| 368 | const sourceNodeId = NodeId.make(`node:${suffix}:b-source`); |
| 369 | const createThread = (input: { |
| 370 | readonly threadId: ThreadId; |
| 371 | readonly projectId: ProjectId; |
| 372 | readonly archived?: boolean; |
| 373 | readonly forkedFromRunOf?: ThreadId; |
| 374 | }) => |
| 375 | projectionStore.apply({ |
| 376 | id: EventId.make(`event:${input.threadId}:created`), |
| 377 | type: "thread.created", |
| 378 | threadId: input.threadId, |
| 379 | occurredAt: now, |
| 380 | payload: { |
| 381 | createdBy: "user", |
| 382 | creationSource: "web", |
| 383 | id: input.threadId, |
| 384 | projectId: input.projectId, |
| 385 | title: input.threadId, |
| 386 | providerInstanceId, |
| 387 | modelSelection, |
| 388 | runtimeMode: "full-access", |
| 389 | interactionMode: "default", |
| 390 | branch: null, |
| 391 | worktreePath: null, |
| 392 | activeProviderThreadId: null, |
| 393 | lineage: |
| 394 | input.forkedFromRunOf === undefined |
| 395 | ? { parentThreadId: null, relationshipToParent: null, rootThreadId: input.threadId } |
| 396 | : { |
| 397 | parentThreadId: input.forkedFromRunOf, |
| 398 | relationshipToParent: "fork", |
| 399 | rootThreadId: input.forkedFromRunOf, |
| 400 | }, |
| 401 | forkedFrom: |
| 402 | input.forkedFromRunOf === undefined |
| 403 | ? null |
| 404 | : { type: "run", threadId: input.forkedFromRunOf, runId: sourceRunId }, |
| 405 | createdAt: now, |
| 406 | updatedAt: now, |
| 407 | archivedAt: input.archived === true ? now : null, |
| 408 | settledOverride: null, |
| 409 | settledAt: null, |
| 410 | lastVisitedAt: null, |
| 411 | deletedAt: null, |
| 412 | }, |
| 413 | }); |
| 414 | |
| 415 | yield* createThread({ threadId: sourceThreadId, projectId: otherProjectId, archived: true }); |
| 416 | yield* createThread({ threadId: otherThreadId, projectId: otherProjectId }); |
| 417 | yield* createThread({ threadId: activeThreadId, projectId }); |
| 418 | yield* createThread({ threadId: archivedThreadId, projectId, archived: true }); |
| 419 | yield* createThread({ threadId: forkThreadId, projectId, forkedFromRunOf: sourceThreadId }); |
| 420 | yield* projectionStore.apply({ |
| 421 | id: EventId.make(`event:${suffix}:source-run`), |
| 422 | type: "run.created", |
| 423 | threadId: sourceThreadId, |
| 424 | runId: sourceRunId, |
| 425 | nodeId: sourceNodeId, |
| 426 | driver, |
| 427 | occurredAt: now, |
| 428 | payload: { |
| 429 | id: sourceRunId, |
| 430 | threadId: sourceThreadId, |
| 431 | ordinal: 1, |
| 432 | providerInstanceId, |
| 433 | modelSelection, |
| 434 | providerThreadId: null, |
| 435 | userMessageId: MessageId.make(`message:${suffix}:source-user`), |
| 436 | rootNodeId: sourceNodeId, |
| 437 | activeAttemptId: null, |
| 438 | status: "completed", |
| 439 | requestedAt: now, |
| 440 | startedAt: now, |
| 441 | completedAt: now, |
| 442 | checkpointId: null, |
| 443 | contextHandoffId: null, |
| 444 | }, |
| 445 | }); |
| 446 | yield* projectionStore.apply({ |
| 447 | id: EventId.make(`event:${suffix}:source-item`), |
| 448 | type: "turn-item.updated", |
| 449 | threadId: sourceThreadId, |
| 450 | runId: sourceRunId, |
| 451 | occurredAt: now, |
| 452 | payload: { |
| 453 | id: TurnItemId.make(`turn-item:${suffix}:source`), |
| 454 | threadId: sourceThreadId, |
| 455 | runId: sourceRunId, |
| 456 | nodeId: null, |
| 457 | providerThreadId: null, |
| 458 | providerTurnId: null, |
| 459 | nativeItemRef: null, |
| 460 | parentItemId: null, |
| 461 | ordinal: 1, |
| 462 | status: "completed", |
| 463 | title: null, |
| 464 | startedAt: now, |
| 465 | completedAt: now, |
| 466 | updatedAt: now, |
| 467 | type: "assistant_message", |
| 468 | messageId: MessageId.make(`message:${suffix}:source-assistant`), |
| 469 | text: "Source answer", |
| 470 | streaming: false, |
| 471 | }, |
| 472 | }); |
| 473 | |
| 474 | const unscoped = yield* projectionStore.getShellSnapshot(); |
| 475 | const inProject = (thread: { readonly projectId: ProjectId }) => thread.projectId === projectId; |
| 476 | const active = yield* projectionStore.getShellSnapshot({ projectId, location: "active" }); |
| 477 | assert.deepEqual(active.threads, unscoped.threads.filter(inProject)); |
| 478 | assert.deepEqual(active.archivedThreads, []); |
| 479 | assert.sameMembers( |
| 480 | active.threads.map((thread) => thread.id), |
| 481 | [activeThreadId, forkThreadId], |
| 482 | ); |
| 483 | const fork = active.threads.find((thread) => thread.id === forkThreadId); |
| 484 | assert.isAbove(fork?.visibleItemCount ?? 0, 0); |
| 485 | |
| 486 | const all = yield* projectionStore.getShellSnapshot({ projectId }); |
| 487 | assert.deepEqual(all.threads, active.threads); |
| 488 | assert.deepEqual(all.archivedThreads, unscoped.archivedThreads.filter(inProject)); |
| 489 | assert.sameMembers( |
| 490 | all.archivedThreads.map((thread) => thread.id), |
| 491 | [archivedThreadId], |
| 492 | ); |
| 493 | return { projectId, activeThreadId, forkThreadId, otherThreadId }; |
| 494 | }); |
| 495 | |
| 496 | it.effect("memory shell snapshots scope to one project", () => |
| 497 | assertProjectScopedShells("memory-project-shell").pipe( |
| 498 | Effect.asVoid, |
| 499 | Effect.provide(ProjectionStore.layerMemory), |
| 500 | ), |
| 501 | ); |
| 502 | |
| 352 | 503 | it.layer(layerTest)("ProjectionStoreV2", (it) => { |
| 353 | 504 | it.effect( |
| 354 | 505 | "keeps restart-cancelled work through a stale run.updated", |
| ... | ... | @@ -1884,6 +2035,35 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { |
| 1884 | 2035 | }), |
| 1885 | 2036 | ); |
| 1886 | 2037 | |
| 2038 | it.effect("reads only the requested project's threads into a scoped shell snapshot", () => |
| 2039 | Effect.gen(function* () { |
| 2040 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; |
| 2041 | const sql = yield* SqlClient.SqlClient; |
| 2042 | const { projectId, activeThreadId, forkThreadId, otherThreadId } = |
| 2043 | yield* assertProjectScopedShells("sql-project-shell"); |
| 2044 | |
| 2045 | // A thread in another project that cannot decode fails the unscoped |
| 2046 | // snapshot, so a scoped snapshot that succeeds never read it. |
| 2047 | const [stored] = yield* sql<{ readonly payload_json: string }>` |
| 2048 | SELECT payload_json FROM orchestration_v2_projection_threads |
| 2049 | WHERE thread_id = ${otherThreadId} |
| 2050 | `; |
| 2051 | const setPayload = (payload: string) => |
| 2052 | sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${payload} |
| 2053 | WHERE thread_id = ${otherThreadId}`; |
| 2054 | yield* setPayload("{}"); |
| 2055 | const [unscoped, scoped] = yield* Effect.all([ |
| 2056 | projectionStore.getShellSnapshot().pipe(Effect.flip), |
| 2057 | projectionStore.getShellSnapshot({ projectId, location: "active" }), |
| 2058 | ]).pipe(Effect.ensuring(Effect.orDie(setPayload(stored!.payload_json)))); |
| 2059 | assert.strictEqual(unscoped._tag, "ProjectionStoreReadError"); |
| 2060 | assert.sameMembers( |
| 2061 | scoped.threads.map((thread) => thread.id), |
| 2062 | [activeThreadId, forkThreadId], |
| 2063 | ); |
| 2064 | }), |
| 2065 | ); |
| 2066 | |
| 1887 | 2067 | it.effect("does not treat visited or marked-unread state as thread activity", () => |
| 1888 | 2068 | Effect.gen(function* () { |
| 1889 | 2069 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; |