authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 18:49:25-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
loga56418f8bc9866ab114c85b4e73a7f95987c3469
tree0d617efbcc51530c20cd1e35e8052bae174f486a
parent596e1590dfd602ffcf1efd77b5d9a0eac465f374

Reuse attachment thread segment sanitizer in projection pipeline

- export and reuse `toSafeThreadAttachmentSegment` from `attachmentStore` - align projection attachment path logic with store-safe thread IDs - update projection pipeline tests to cover thread IDs with spaces

3 files changed, 36 insertions(+), 47 deletions(-)

apps/server/src/attachmentStore.ts+1-1
......@@ -13,7 +13,7 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"
1313const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
1414const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
1515
16function toSafeThreadAttachmentSegment(threadId: string): string | null {
16export function toSafeThreadAttachmentSegment(threadId: string): string | null {
1717 const segment = threadId
1818 .trim()
1919 .replace(/[^a-z0-9_-]+/gi, "-")
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+29-24
......@@ -733,6 +733,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
733733 const projectionPipeline = yield* OrchestrationProjectionPipeline;
734734 const eventStore = yield* OrchestrationEventStore;
735735 const now = new Date().toISOString();
736 const threadId = ThreadId.makeUnsafe("thread revert.files");
737 const keepAttachmentId = "thread-revert-files-keep";
738 const removeAttachmentId = "thread-revert-files-remove";
736739
737740 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
738741 eventStore
......@@ -764,14 +767,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
764767 type: "thread.created",
765768 eventId: EventId.makeUnsafe("evt-revert-files-2"),
766769 aggregateKind: "thread",
767 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
770 aggregateId: threadId,
768771 occurredAt: now,
769772 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
770773 causationEventId: null,
771774 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
772775 metadata: {},
773776 payload: {
774 threadId: ThreadId.makeUnsafe("thread-revert-files"),
777 threadId,
775778 projectId: ProjectId.makeUnsafe("project-revert-files"),
776779 title: "Thread Revert Files",
777780 model: "gpt-5-codex",
......@@ -786,14 +789,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
786789 type: "thread.turn-diff-completed",
787790 eventId: EventId.makeUnsafe("evt-revert-files-3"),
788791 aggregateKind: "thread",
789 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
792 aggregateId: threadId,
790793 occurredAt: now,
791794 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
792795 causationEventId: null,
793796 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
794797 metadata: {},
795798 payload: {
796 threadId: ThreadId.makeUnsafe("thread-revert-files"),
799 threadId,
797800 turnId: TurnId.makeUnsafe("turn-keep"),
798801 checkpointTurnCount: 1,
799802 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),
......@@ -808,21 +811,21 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
808811 type: "thread.message-sent",
809812 eventId: EventId.makeUnsafe("evt-revert-files-4"),
810813 aggregateKind: "thread",
811 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
814 aggregateId: threadId,
812815 occurredAt: now,
813816 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
814817 causationEventId: null,
815818 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
816819 metadata: {},
817820 payload: {
818 threadId: ThreadId.makeUnsafe("thread-revert-files"),
821 threadId,
819822 messageId: MessageId.makeUnsafe("message-keep"),
820823 role: "assistant",
821824 text: "Keep",
822825 attachments: [
823826 {
824827 type: "image",
825 id: "thread-revert-files-keep",
828 id: keepAttachmentId,
826829 name: "keep.png",
827830 mimeType: "image/png",
828831 sizeBytes: 5,
......@@ -839,14 +842,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
839842 type: "thread.turn-diff-completed",
840843 eventId: EventId.makeUnsafe("evt-revert-files-5"),
841844 aggregateKind: "thread",
842 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
845 aggregateId: threadId,
843846 occurredAt: now,
844847 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),
845848 causationEventId: null,
846849 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),
847850 metadata: {},
848851 payload: {
849 threadId: ThreadId.makeUnsafe("thread-revert-files"),
852 threadId,
850853 turnId: TurnId.makeUnsafe("turn-remove"),
851854 checkpointTurnCount: 2,
852855 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"),
......@@ -861,21 +864,21 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
861864 type: "thread.message-sent",
862865 eventId: EventId.makeUnsafe("evt-revert-files-6"),
863866 aggregateKind: "thread",
864 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
867 aggregateId: threadId,
865868 occurredAt: now,
866869 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
867870 causationEventId: null,
868871 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
869872 metadata: {},
870873 payload: {
871 threadId: ThreadId.makeUnsafe("thread-revert-files"),
874 threadId,
872875 messageId: MessageId.makeUnsafe("message-remove"),
873876 role: "assistant",
874877 text: "Remove",
875878 attachments: [
876879 {
877880 type: "image",
878 id: "thread-revert-files-remove",
881 id: removeAttachmentId,
879882 name: "remove.png",
880883 mimeType: "image/png",
881884 sizeBytes: 5,
......@@ -891,12 +894,12 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
891894 const keepPath = path.join(
892895 stateDir,
893896 "attachments",
894 "thread-revert-files-keep.png",
897 `${keepAttachmentId}.png`,
895898 );
896899 const removePath = path.join(
897900 stateDir,
898901 "attachments",
899 "thread-revert-files-remove.png",
902 `${removeAttachmentId}.png`,
900903 );
901904 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
902905 fs.writeFileSync(keepPath, Buffer.from("keep"));
......@@ -908,14 +911,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
908911 type: "thread.reverted",
909912 eventId: EventId.makeUnsafe("evt-revert-files-7"),
910913 aggregateKind: "thread",
911 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
914 aggregateId: threadId,
912915 occurredAt: now,
913916 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
914917 causationEventId: null,
915918 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),
916919 metadata: {},
917920 payload: {
918 threadId: ThreadId.makeUnsafe("thread-revert-files"),
921 threadId,
919922 turnCount: 1,
920923 },
921924 });
......@@ -939,6 +942,8 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
939942 const projectionPipeline = yield* OrchestrationProjectionPipeline;
940943 const eventStore = yield* OrchestrationEventStore;
941944 const now = new Date().toISOString();
945 const threadId = ThreadId.makeUnsafe("thread delete.files");
946 const attachmentId = "thread-delete-files-att-1";
942947
943948 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
944949 eventStore
......@@ -970,14 +975,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
970975 type: "thread.created",
971976 eventId: EventId.makeUnsafe("evt-delete-files-2"),
972977 aggregateKind: "thread",
973 aggregateId: ThreadId.makeUnsafe("thread-delete-files"),
978 aggregateId: threadId,
974979 occurredAt: now,
975980 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),
976981 causationEventId: null,
977982 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),
978983 metadata: {},
979984 payload: {
980 threadId: ThreadId.makeUnsafe("thread-delete-files"),
985 threadId,
981986 projectId: ProjectId.makeUnsafe("project-delete-files"),
982987 title: "Thread Delete Files",
983988 model: "gpt-5-codex",
......@@ -992,21 +997,21 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
992997 type: "thread.message-sent",
993998 eventId: EventId.makeUnsafe("evt-delete-files-3"),
994999 aggregateKind: "thread",
995 aggregateId: ThreadId.makeUnsafe("thread-delete-files"),
1000 aggregateId: threadId,
9961001 occurredAt: now,
9971002 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),
9981003 causationEventId: null,
9991004 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),
10001005 metadata: {},
10011006 payload: {
1002 threadId: ThreadId.makeUnsafe("thread-delete-files"),
1007 threadId,
10031008 messageId: MessageId.makeUnsafe("message-delete-files"),
10041009 role: "user",
10051010 text: "Delete",
10061011 attachments: [
10071012 {
10081013 type: "image",
1009 id: "thread-delete-files-att-1",
1014 id: attachmentId,
10101015 name: "delete.png",
10111016 mimeType: "image/png",
10121017 sizeBytes: 5,
......@@ -1019,7 +1024,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
10191024 },
10201025 });
10211026
1022 const threadAttachmentPath = path.join(stateDir, "attachments", "thread-delete-files-att-1.png");
1027 const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`);
10231028 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
10241029 fs.writeFileSync(threadAttachmentPath, Buffer.from("delete"));
10251030 assert.equal(fs.existsSync(threadAttachmentPath), true);
......@@ -1028,14 +1033,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
10281033 type: "thread.deleted",
10291034 eventId: EventId.makeUnsafe("evt-delete-files-4"),
10301035 aggregateKind: "thread",
1031 aggregateId: ThreadId.makeUnsafe("thread-delete-files"),
1036 aggregateId: threadId,
10321037 occurredAt: now,
10331038 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),
10341039 causationEventId: null,
10351040 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),
10361041 metadata: {},
10371042 payload: {
1038 threadId: ThreadId.makeUnsafe("thread-delete-files"),
1043 threadId,
10391044 deletedAt: now,
10401045 },
10411046 });
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+6-22
......@@ -37,7 +37,7 @@ import {
3737 OrchestrationProjectionPipeline,
3838 type OrchestrationProjectionPipelineShape,
3939} from "../Services/ProjectionPipeline.ts";
40import { attachmentRelativePath } from "../../attachmentStore.ts";
40import { attachmentRelativePath, toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";
4141
4242export const ORCHESTRATION_PROJECTOR_NAMES = {
4343 projects: "projection.projects",
......@@ -66,27 +66,11 @@ interface AttachmentSideEffects {
6666 readonly prunedThreadRelativePaths: Map<string, Set<string>>;
6767}
6868
69function toSafeThreadAttachmentSegment(threadId: string): string | null {
70 const segment = encodeURIComponent(threadId);
71 if (
72 segment.length === 0 ||
73 segment === "." ||
74 segment === ".." ||
75 segment.includes("/") ||
76 segment.includes("\\") ||
77 segment.includes("\0")
78 ) {
79 return null;
80 }
81 return segment;
82}
83
84const materializeAttachmentsForProjection = Effect.fn(function* (input: {
85 readonly attachments: ReadonlyArray<ChatAttachment>;
86}) {
87 if (input.attachments.length === 0) return [];
88 return input.attachments;
89});
69const materializeAttachmentsForProjection = Effect.fn(
70 (input: {
71 readonly attachments: ReadonlyArray<ChatAttachment>;
72 }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments),
73);
9074
9175function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
9276 if (typeof payload !== "object" || payload === null) {