authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 09:42:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 09:42:59-07:00
log5c03ced3be8444567db4b50a474619bc5e63db98
tree0dec8dbf1c1a4f2dc037652c8627a61d9e253e4f
parentca3f9738c39c3502dd2258aae872f971080969ef
signature Signed by PGP key B5690EEEBB952194

refactor(contracts): project icons decode forward-compatibly instead of encoding a fallback (#16118)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 101 insertions(+), 103 deletions(-)

apps/server/src/orchestration-v2/ProjectStore.ts+3-3
...@@ -3,7 +3,7 @@ import {...@@ -3,7 +3,7 @@ import {
3 IsoDateTime,3 IsoDateTime,
4 ModelSelection,4 ModelSelection,
5 type OrchestrationProjectShell,5 type OrchestrationProjectShell,
6 ProjectIconOverride,6 StoredProjectIcon,
7 ProjectId,7 ProjectId,
8 ProjectScript,8 ProjectScript,
9 ThreadEnvMode,9 ThreadEnvMode,
...@@ -37,7 +37,7 @@ export const ProjectRow = Schema.Struct({...@@ -37,7 +37,7 @@ export const ProjectRow = Schema.Struct({
37 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),37 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
38 autoPull: Schema.Boolean,38 autoPull: Schema.Boolean,
39 faviconPath: Schema.NullOr(Schema.String),39 faviconPath: Schema.NullOr(Schema.String),
40 projectIcon: Schema.NullOr(ProjectIconOverride),40 projectIcon: Schema.NullOr(StoredProjectIcon),
41 scripts: Schema.Array(ProjectScript),41 scripts: Schema.Array(ProjectScript),
42 createdAt: IsoDateTime,42 createdAt: IsoDateTime,
43 updatedAt: IsoDateTime,43 updatedAt: IsoDateTime,
...@@ -49,7 +49,7 @@ const ProjectDbRow = Schema.Struct({...@@ -49,7 +49,7 @@ const ProjectDbRow = Schema.Struct({
49 ...ProjectRow.fields,49 ...ProjectRow.fields,
50 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),50 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
51 autoPull: Schema.BooleanFromBit,51 autoPull: Schema.BooleanFromBit,
52 projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)),52 projectIcon: Schema.NullOr(Schema.fromJsonString(StoredProjectIcon)),
53 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),53 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
54});54});
5555
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+2-2
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 OrchestrationV2DomainEventJson,9 OrchestrationV2DomainEventJson,
10 OrchestrationV2StoredEvent,10 OrchestrationV2StoredEvent,
11 ProjectId,11 ProjectId,
12 ProjectIconOverride,12 StoredProjectIcon,
13 ThreadId,13 ThreadId,
14 type OrchestrationV2DomainEvent,14 type OrchestrationV2DomainEvent,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
...@@ -31,7 +31,7 @@ import {...@@ -31,7 +31,7 @@ import {
31} from "../Errors.ts";31} from "../Errors.ts";
32import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts";32import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts";
3333
34const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride);34const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon);
35const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent);35const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent);
36const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);36const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
37const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata);37const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata);
packages/contracts/src/applicationEvent.ts+3-3
...@@ -14,7 +14,7 @@ import {...@@ -14,7 +14,7 @@ import {
14import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";14import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
15import { ModelSelection } from "./modelSelection.ts";15import { ModelSelection } from "./modelSelection.ts";
16import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts";16import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts";
17import { ProjectIconOverride, ProjectScript } from "./project.ts";17import { ProjectScript, StoredProjectIcon } from "./project.ts";
1818
19/**19/**
20 * Which client dispatched the command that produced this event (#7774).20 * Which client dispatched the command that produced this event (#7774).
...@@ -51,7 +51,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({...@@ -51,7 +51,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({
51 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),51 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
52 // Optional so persisted events from older servers still decode.52 // Optional so persisted events from older servers still decode.
53 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),53 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
54 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),54 projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)),
55 scripts: Schema.Array(ProjectScript),55 scripts: Schema.Array(ProjectScript),
56 createdAt: IsoDateTime,56 createdAt: IsoDateTime,
57 updatedAt: IsoDateTime,57 updatedAt: IsoDateTime,
...@@ -68,7 +68,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({...@@ -68,7 +68,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({
68 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),68 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
69 autoPull: Schema.optional(Schema.Boolean),69 autoPull: Schema.optional(Schema.Boolean),
70 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),70 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
71 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),71 projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)),
72 scripts: Schema.optional(Schema.Array(ProjectScript)),72 scripts: Schema.optional(Schema.Array(ProjectScript)),
73 updatedAt: IsoDateTime,73 updatedAt: IsoDateTime,
74});74});
packages/contracts/src/orchestrationProject.ts+2-2
...@@ -3,7 +3,7 @@ import * as Schema from "effect/Schema";...@@ -3,7 +3,7 @@ import * as Schema from "effect/Schema";
3import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts";3import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";4import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
5import { ModelSelection } from "./modelSelection.ts";5import { ModelSelection } from "./modelSelection.ts";
6import { ProjectIconOverride, ProjectScript } from "./project.ts";6import { ProjectScript, ReceivedProjectIcon } from "./project.ts";
77
8/** Project summary shared by the V2 shell and application project APIs. */8/** Project summary shared by the V2 shell and application project APIs. */
9export const OrchestrationProjectShell = Schema.Struct({9export const OrchestrationProjectShell = Schema.Struct({
...@@ -20,7 +20,7 @@ export const OrchestrationProjectShell = Schema.Struct({...@@ -20,7 +20,7 @@ export const OrchestrationProjectShell = Schema.Struct({
20 autoPull: Schema.optional(Schema.Boolean),20 autoPull: Schema.optional(Schema.Boolean),
21 // Optional on the wire so cached snapshots from older servers still decode.21 // Optional on the wire so cached snapshots from older servers still decode.
22 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),22 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
23 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),23 projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)),
24 scripts: Schema.Array(ProjectScript),24 scripts: Schema.Array(ProjectScript),
25 createdAt: IsoDateTime,25 createdAt: IsoDateTime,
26 updatedAt: IsoDateTime,26 updatedAt: IsoDateTime,
packages/contracts/src/project.test.ts+34-62
...@@ -8,7 +8,8 @@ import { OrchestrationProjectShell } from "./orchestrationProject.ts";...@@ -8,7 +8,8 @@ import { OrchestrationProjectShell } from "./orchestrationProject.ts";
88
9import {9import {
10 ProjectFaviconPath,10 ProjectFaviconPath,
11 ProjectIconOverride,11 ReceivedProjectIcon,
12 StoredProjectIcon,
12 ProjectReadFileError,13 ProjectReadFileError,
13 ProjectCreatePayload,14 ProjectCreatePayload,
14 ProjectUpdatePayload,15 ProjectUpdatePayload,
...@@ -204,85 +205,56 @@ effectIt.effect("project monograms validate text and palette colors", () =>...@@ -204,85 +205,56 @@ effectIt.effect("project monograms validate text and palette colors", () =>
204 }),205 }),
205);206);
206207
207const decodeProjectIcon = Schema.decodeUnknownEffect(ProjectIconOverride);208const decodeStoredIcon = Schema.decodeUnknownEffect(StoredProjectIcon);
208const encodeProjectIcon = Schema.encodeEffect(ProjectIconOverride);209const encodeStoredIcon = Schema.encodeEffect(StoredProjectIcon);
210const decodeReceivedIcon = Schema.decodeUnknownEffect(ReceivedProjectIcon);
211const encodeReceivedIcon = Schema.encodeEffect(ReceivedProjectIcon);
212const decodeProjectShell = Schema.decodeUnknownEffect(OrchestrationProjectShell);
209213
210// Pre-monogram clients reject unknown variants; nightly clients additionally validate monogram.214effectIt.effect("sends and stores icons in their plain shape", () =>
211const decodeOldIcon = Schema.decodeUnknownEffect(
212 Schema.Union([
213 Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: Schema.String }),
214 Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }),
215 ]),
216);
217const decodeNightlyIcon = Schema.decodeUnknownEffect(
218 Schema.Union([
219 Schema.Struct({
220 kind: Schema.Literal("lucide"),
221 name: Schema.String,
222 color: Schema.String,
223 // Fail if this field is ever sent; old validators must never see the new text.
224 monogram: Schema.optional(Schema.Never),
225 }),
226 Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }),
227 ]),
228);
229
230effectIt.effect("sends monograms as fallback icons that old and nightly clients can decode", () =>
231 Effect.gen(function* () {215 Effect.gen(function* () {
232 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;
233 for (const text of ["T3", "क्ष्म", "e\u0301"]) {
234 const monogram = { kind: "monogram", text, color: "violet" } as const;
235 const wire = yield* encodeProjectIcon(monogram);
236 assert.deepEqual(wire, { ...fallback, monogramText: text });
237 assert.deepEqual(yield* decodeOldIcon(wire), fallback);
238 assert.deepEqual(yield* decodeNightlyIcon(wire), fallback);
239 assert.deepEqual(yield* decodeProjectIcon(wire), monogram);
240 assert.deepEqual(yield* decodeProjectIcon(monogram), monogram);
241 assert.deepEqual(yield* decodeProjectIcon({ ...fallback, monogram: text }), monogram);
242 }
243 for (const icon of [216 for (const icon of [
217 { kind: "monogram", text: "क्ष्म", color: "violet" },
244 { kind: "lucide", name: "alarm-clock", color: "blue" },218 { kind: "lucide", name: "alarm-clock", color: "blue" },
245 { kind: "emoji", emoji: "🚀" },219 { kind: "emoji", emoji: "🚀" },
246 ] as const) {220 ] as const) {
247 assert.deepEqual(yield* decodeProjectIcon(icon), icon);221 assert.deepEqual(yield* encodeReceivedIcon(icon), icon);
248 assert.deepEqual(yield* encodeProjectIcon(icon), icon);222 assert.deepEqual(yield* encodeStoredIcon(icon), icon);
223 assert.deepEqual(yield* decodeReceivedIcon(icon), icon);
249 }224 }
250 }),225 }),
251);226);
252227
253const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell);228effectIt.effect("reads monograms stored in the pre-v2 fallback shape", () =>
254const encodeProjectUpdate = Schema.encodeEffect(ProjectUpdatePayload);229 Effect.gen(function* () {
255const decodeLegacyShell = Schema.decodeUnknownEffect(230 const monogram = { kind: "monogram", text: "T3", color: "violet" } as const;
256 Schema.Struct({231 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;
257 ...OrchestrationProjectShell.fields,232 for (const legacy of [
258 projectIcon: Schema.optional(233 { ...fallback, monogramText: "T3" },
259 Schema.NullOr(234 { ...fallback, monogram: "T3" },
260 Schema.Struct({235 ]) {
261 kind: Schema.Literal("lucide"),236 assert.deepEqual(yield* decodeStoredIcon(legacy), monogram);
262 name: Schema.String,237 assert.deepEqual(yield* decodeReceivedIcon(legacy), monogram);
263 color: Schema.String,238 }
264 }),
265 ),
266 ),
267 }),239 }),
268);240);
269241
270effectIt.effect("encodes compatible icons inside snapshots and project updates", () =>242effectIt.effect("an icon kind from a newer server shows the default icon", () =>
271 Effect.gen(function* () {243 Effect.gen(function* () {
272 const projectIcon = { kind: "monogram", text: "क्ष्म", color: "violet" } as const;244 assert.isNull(yield* decodeReceivedIcon({ kind: "image", url: "https://example.com/a.png" }));
273 const shell = yield* encodeProjectShell({245 const shell = yield* decodeProjectShell({
274 id: ProjectId.make("monogram"),246 id: "project-1",
275 title: "Monogram",247 title: "Project",
276 workspaceRoot: "/tmp/monogram",248 workspaceRoot: "/tmp/project",
277 defaultModelSelection: null,249 defaultModelSelection: null,
278 scripts: [],250 scripts: [],
279 projectIcon,251 projectIcon: { kind: "image", url: "https://example.com/a.png" },
280 createdAt: "2026-01-01T00:00:00.000Z",252 createdAt: "2026-01-01T00:00:00.000Z",
281 updatedAt: "2026-01-01T00:00:00.000Z",253 updatedAt: "2026-01-01T00:00:00.000Z",
282 });254 });
283 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;255 assert.isNull(shell.projectIcon);
284 assert.deepEqual((yield* decodeLegacyShell(shell)).projectIcon, fallback);256 // A known kind with a broken payload still fails.
285 const update = yield* encodeProjectUpdate({ projectIcon });257 const broken = yield* Effect.exit(decodeReceivedIcon({ kind: "emoji" }));
286 assert.deepEqual(yield* decodeNightlyIcon(update.projectIcon), fallback);258 assert.strictEqual(broken._tag, "Failure");
287 }),259 }),
288);260);
packages/contracts/src/project.ts+57-31
...@@ -4,12 +4,15 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";...@@ -4,12 +4,15 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
4import { ModelSelection } from "./modelSelection.ts";4import { ModelSelection } from "./modelSelection.ts";
5import {5import {
6 CommandId,6 CommandId,
7 ForwardCompatibleUnion,
8 isUnknownUnionMember,
7 IsoDateTime,9 IsoDateTime,
8 NonNegativeInt,10 NonNegativeInt,
9 PositiveInt,11 PositiveInt,
10 ProjectId,12 ProjectId,
11 TrimmedNonEmptyString,13 TrimmedNonEmptyString,
12 TrimmedString,14 TrimmedString,
15 type UnknownUnionMember,
13} from "./baseSchemas.ts";16} from "./baseSchemas.ts";
1417
15const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;18const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
...@@ -89,13 +92,6 @@ const ProjectMonogramIcon = Schema.Struct({...@@ -89,13 +92,6 @@ const ProjectMonogramIcon = Schema.Struct({
89 text: ProjectMonogramText,92 text: ProjectMonogramText,
90 color: ProjectIconColor,93 color: ProjectIconColor,
91});94});
92const ProjectIcon = Schema.Union([ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon]);
93const ProjectLucideIconWire = Schema.Struct({
94 ...ProjectLucideIcon.fields,
95 monogramText: Schema.optional(ProjectMonogramText),
96 monogram: Schema.optional(ProjectMonogramText),
97});
98
99/** A workspace-relative image a project may use as its favicon. */95/** A workspace-relative image a project may use as its favicon. */
100export const ProjectFaviconPath = TrimmedNonEmptyString.check(96export const ProjectFaviconPath = TrimmedNonEmptyString.check(
101 Schema.isMaxLength(1024),97 Schema.isMaxLength(1024),
...@@ -103,36 +99,66 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check(...@@ -103,36 +99,66 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check(
103);99);
104export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;100export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
105101
106// Older peers only know lucide/emoji. Keep monograms out of their validated102/** A project's chosen icon, as clients set it. */
107// `monogram` field too: old grapheme counters can reject otherwise valid text.
108export const ProjectIconOverride = Schema.Union([103export const ProjectIconOverride = Schema.Union([
109 ProjectLucideIconWire,104 ProjectLucideIcon,
110 ProjectEmojiIcon,105 ProjectEmojiIcon,
111 ProjectMonogramIcon,106 ProjectMonogramIcon,
112]).pipe(107]);
108export type ProjectIconOverride = typeof ProjectIconOverride.Type;
109
110/**
111 * Before v2, servers sent a monogram as a lucide icon carrying its text, so
112 * older clients showed a folder. Icons stored then, and v2 servers released
113 * before this change, still use that shape; it is read here, never written.
114 */
115const ProjectLucideIconWithLegacyMonogram = Schema.Struct({
116 ...ProjectLucideIcon.fields,
117 monogramText: Schema.optional(ProjectMonogramText),
118 monogram: Schema.optional(ProjectMonogramText),
119});
120const projectIconMembers = [
121 ProjectLucideIconWithLegacyMonogram,
122 ProjectEmojiIcon,
123 ProjectMonogramIcon,
124] as const;
125type ProjectIconMember = (typeof projectIconMembers)[number]["Type"];
126
127const fromLegacyMonogram = (icon: ProjectIconMember): ProjectIconOverride => {
128 if (icon.kind !== "lucide") return icon;
129 const text = icon.monogramText ?? icon.monogram;
130 return text === undefined
131 ? { kind: "lucide", name: icon.name, color: icon.color }
132 : { kind: "monogram", text, color: icon.color };
133};
134
135/** An icon the server stores; it reads legacy monograms and writes the plain shape. */
136export const StoredProjectIcon = Schema.Union(projectIconMembers).pipe(
113 Schema.decodeTo(137 Schema.decodeTo(
114 ProjectIcon,138 Schema.toType(ProjectIconOverride),
115 SchemaTransformation.transform({139 SchemaTransformation.transform<ProjectIconOverride, ProjectIconMember>({
116 decode: (icon): typeof ProjectIcon.Type => {140 decode: fromLegacyMonogram,
117 if (icon.kind !== "lucide") return icon;141 encode: (icon) => icon,
118 const text = icon.monogramText ?? icon.monogram;142 }),
119 return text === undefined143 ),
120 ? { kind: "lucide", name: icon.name, color: icon.color }144);
121 : { kind: "monogram", text, color: icon.color };145
122 },146/**
123 encode: (icon) =>147 * An icon as clients receive it. A kind from a newer server decodes as no
124 icon.kind === "monogram"148 * override, so the client shows the project's default icon.
125 ? {149 */
126 kind: "lucide" as const,150export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "kind").pipe(
127 name: "folder-code",151 Schema.decodeTo(
128 color: icon.color,152 Schema.NullOr(Schema.toType(ProjectIconOverride)),
129 monogramText: icon.text,153 SchemaTransformation.transform<
130 }154 ProjectIconOverride | null,
131 : icon,155 ProjectIconMember | UnknownUnionMember<"kind">
156 >({
157 decode: (icon) => (isUnknownUnionMember(icon) ? null : fromLegacyMonogram(icon)),
158 encode: (icon) => icon as ProjectIconMember,
132 }),159 }),
133 ),160 ),
134);161);
135export type ProjectIconOverride = typeof ProjectIconOverride.Type;
136162
137export const Project = Schema.Struct({163export const Project = Schema.Struct({
138 id: ProjectId,164 id: ProjectId,
...@@ -140,7 +166,7 @@ export const Project = Schema.Struct({...@@ -140,7 +166,7 @@ export const Project = Schema.Struct({
140 workspaceRoot: TrimmedNonEmptyString,166 workspaceRoot: TrimmedNonEmptyString,
141 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),167 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
142 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),168 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
143 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),169 projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)),
144 defaultModelSelection: Schema.NullOr(ModelSelection),170 defaultModelSelection: Schema.NullOr(ModelSelection),
145 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),171 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
146 // Opt-in because background sync performs network I/O and may move the checkout.172 // Opt-in because background sync performs network I/O and may move the checkout.