diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index e576849bc062ce86da74b53d64e98ea1e21ce387..ba4cdd2aaeff342941cf674d4ce09a765fe9bdda 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -3,7 +3,7 @@ import { IsoDateTime, ModelSelection, type OrchestrationProjectShell, - ProjectIconOverride, + StoredProjectIcon, ProjectId, ProjectScript, ThreadEnvMode, @@ -37,7 +37,7 @@ export const ProjectRow = Schema.Struct({ defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), autoPull: Schema.Boolean, faviconPath: Schema.NullOr(Schema.String), - projectIcon: Schema.NullOr(ProjectIconOverride), + projectIcon: Schema.NullOr(StoredProjectIcon), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -49,7 +49,7 @@ const ProjectDbRow = Schema.Struct({ ...ProjectRow.fields, defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), autoPull: Schema.BooleanFromBit, - projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), + projectIcon: Schema.NullOr(Schema.fromJsonString(StoredProjectIcon)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index f947d2b74c66836512b8022bd71ba276829ffd36..26eca3c7d855acc04afd77d0b61294ab99c192b2 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -9,7 +9,7 @@ import { OrchestrationV2DomainEventJson, OrchestrationV2StoredEvent, ProjectId, - ProjectIconOverride, + StoredProjectIcon, ThreadId, type OrchestrationV2DomainEvent, } from "@t3tools/contracts"; @@ -31,7 +31,7 @@ import { } from "../Errors.ts"; import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; -const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride); +const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon); const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent); const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata); diff --git a/packages/contracts/src/applicationEvent.ts b/packages/contracts/src/applicationEvent.ts index 0c69aa59c1ed896edeb73d076280efb795774fdb..6b712889c8487ad1b438bdec565415245e7f8938 100644 --- a/packages/contracts/src/applicationEvent.ts +++ b/packages/contracts/src/applicationEvent.ts @@ -14,7 +14,7 @@ import { import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts"; -import { ProjectIconOverride, ProjectScript } from "./project.ts"; +import { ProjectScript, StoredProjectIcon } from "./project.ts"; /** * Which client dispatched the command that produced this event (#7774). @@ -51,7 +51,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({ defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), // Optional so persisted events from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -68,7 +68,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({ defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), autoPull: Schema.optional(Schema.Boolean), faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/orchestrationProject.ts b/packages/contracts/src/orchestrationProject.ts index 7aadf2917e1aa5fae1d725b2e0d4252da034bfbd..4248558c93adac9d2b3242f41737932c24f9289c 100644 --- a/packages/contracts/src/orchestrationProject.ts +++ b/packages/contracts/src/orchestrationProject.ts @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; -import { ProjectIconOverride, ProjectScript } from "./project.ts"; +import { ProjectScript, ReceivedProjectIcon } from "./project.ts"; /** Project summary shared by the V2 shell and application project APIs. */ export const OrchestrationProjectShell = Schema.Struct({ @@ -20,7 +20,7 @@ export const OrchestrationProjectShell = Schema.Struct({ autoPull: Schema.optional(Schema.Boolean), // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 62c458a7cb4ed9d543cc141ff940f7e9dd92211f..81c260aa2725ed3916ae698a7f0f606e42a8a979 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -8,7 +8,8 @@ import { OrchestrationProjectShell } from "./orchestrationProject.ts"; import { ProjectFaviconPath, - ProjectIconOverride, + ReceivedProjectIcon, + StoredProjectIcon, ProjectReadFileError, ProjectCreatePayload, ProjectUpdatePayload, @@ -204,85 +205,56 @@ effectIt.effect("project monograms validate text and palette colors", () => }), ); -const decodeProjectIcon = Schema.decodeUnknownEffect(ProjectIconOverride); -const encodeProjectIcon = Schema.encodeEffect(ProjectIconOverride); +const decodeStoredIcon = Schema.decodeUnknownEffect(StoredProjectIcon); +const encodeStoredIcon = Schema.encodeEffect(StoredProjectIcon); +const decodeReceivedIcon = Schema.decodeUnknownEffect(ReceivedProjectIcon); +const encodeReceivedIcon = Schema.encodeEffect(ReceivedProjectIcon); +const decodeProjectShell = Schema.decodeUnknownEffect(OrchestrationProjectShell); -// Pre-monogram clients reject unknown variants; nightly clients additionally validate monogram. -const decodeOldIcon = Schema.decodeUnknownEffect( - Schema.Union([ - Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: Schema.String }), - Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), - ]), -); -const decodeNightlyIcon = Schema.decodeUnknownEffect( - Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("lucide"), - name: Schema.String, - color: Schema.String, - // Fail if this field is ever sent; old validators must never see the new text. - monogram: Schema.optional(Schema.Never), - }), - Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), - ]), -); - -effectIt.effect("sends monograms as fallback icons that old and nightly clients can decode", () => +effectIt.effect("sends and stores icons in their plain shape", () => Effect.gen(function* () { - const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; - for (const text of ["T3", "क्ष्म", "e\u0301"]) { - const monogram = { kind: "monogram", text, color: "violet" } as const; - const wire = yield* encodeProjectIcon(monogram); - assert.deepEqual(wire, { ...fallback, monogramText: text }); - assert.deepEqual(yield* decodeOldIcon(wire), fallback); - assert.deepEqual(yield* decodeNightlyIcon(wire), fallback); - assert.deepEqual(yield* decodeProjectIcon(wire), monogram); - assert.deepEqual(yield* decodeProjectIcon(monogram), monogram); - assert.deepEqual(yield* decodeProjectIcon({ ...fallback, monogram: text }), monogram); - } for (const icon of [ + { kind: "monogram", text: "क्ष्म", color: "violet" }, { kind: "lucide", name: "alarm-clock", color: "blue" }, { kind: "emoji", emoji: "🚀" }, ] as const) { - assert.deepEqual(yield* decodeProjectIcon(icon), icon); - assert.deepEqual(yield* encodeProjectIcon(icon), icon); + assert.deepEqual(yield* encodeReceivedIcon(icon), icon); + assert.deepEqual(yield* encodeStoredIcon(icon), icon); + assert.deepEqual(yield* decodeReceivedIcon(icon), icon); } }), ); -const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); -const encodeProjectUpdate = Schema.encodeEffect(ProjectUpdatePayload); -const decodeLegacyShell = Schema.decodeUnknownEffect( - Schema.Struct({ - ...OrchestrationProjectShell.fields, - projectIcon: Schema.optional( - Schema.NullOr( - Schema.Struct({ - kind: Schema.Literal("lucide"), - name: Schema.String, - color: Schema.String, - }), - ), - ), +effectIt.effect("reads monograms stored in the pre-v2 fallback shape", () => + Effect.gen(function* () { + const monogram = { kind: "monogram", text: "T3", color: "violet" } as const; + const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; + for (const legacy of [ + { ...fallback, monogramText: "T3" }, + { ...fallback, monogram: "T3" }, + ]) { + assert.deepEqual(yield* decodeStoredIcon(legacy), monogram); + assert.deepEqual(yield* decodeReceivedIcon(legacy), monogram); + } }), ); -effectIt.effect("encodes compatible icons inside snapshots and project updates", () => +effectIt.effect("an icon kind from a newer server shows the default icon", () => Effect.gen(function* () { - const projectIcon = { kind: "monogram", text: "क्ष्म", color: "violet" } as const; - const shell = yield* encodeProjectShell({ - id: ProjectId.make("monogram"), - title: "Monogram", - workspaceRoot: "/tmp/monogram", + assert.isNull(yield* decodeReceivedIcon({ kind: "image", url: "https://example.com/a.png" })); + const shell = yield* decodeProjectShell({ + id: "project-1", + title: "Project", + workspaceRoot: "/tmp/project", defaultModelSelection: null, scripts: [], - projectIcon, + projectIcon: { kind: "image", url: "https://example.com/a.png" }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; - assert.deepEqual((yield* decodeLegacyShell(shell)).projectIcon, fallback); - const update = yield* encodeProjectUpdate({ projectIcon }); - assert.deepEqual(yield* decodeNightlyIcon(update.projectIcon), fallback); + assert.isNull(shell.projectIcon); + // A known kind with a broken payload still fails. + const broken = yield* Effect.exit(decodeReceivedIcon({ kind: "emoji" })); + assert.strictEqual(broken._tag, "Failure"); }), ); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index a1a3d17e9c53e992cd60bee159342bf8827bf8e7..97997ef3deebac3f107bf004bd3a1554eb6e9dfc 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -4,12 +4,15 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; import { CommandId, + ForwardCompatibleUnion, + isUnknownUnionMember, IsoDateTime, NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString, TrimmedString, + type UnknownUnionMember, } from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; @@ -89,13 +92,6 @@ const ProjectMonogramIcon = Schema.Struct({ text: ProjectMonogramText, color: ProjectIconColor, }); -const ProjectIcon = Schema.Union([ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon]); -const ProjectLucideIconWire = Schema.Struct({ - ...ProjectLucideIcon.fields, - monogramText: Schema.optional(ProjectMonogramText), - monogram: Schema.optional(ProjectMonogramText), -}); - /** A workspace-relative image a project may use as its favicon. */ export const ProjectFaviconPath = TrimmedNonEmptyString.check( Schema.isMaxLength(1024), @@ -103,44 +99,74 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check( ); export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; -// Older peers only know lucide/emoji. Keep monograms out of their validated -// `monogram` field too: old grapheme counters can reject otherwise valid text. +/** A project's chosen icon, as clients set it. */ export const ProjectIconOverride = Schema.Union([ - ProjectLucideIconWire, + ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon, -]).pipe( - Schema.decodeTo( - ProjectIcon, - SchemaTransformation.transform({ - decode: (icon): typeof ProjectIcon.Type => { - if (icon.kind !== "lucide") return icon; - const text = icon.monogramText ?? icon.monogram; - return text === undefined - ? { kind: "lucide", name: icon.name, color: icon.color } - : { kind: "monogram", text, color: icon.color }; - }, - encode: (icon) => - icon.kind === "monogram" - ? { - kind: "lucide" as const, - name: "folder-code", - color: icon.color, - monogramText: icon.text, - } - : icon, - }), - ), -); +]); export type ProjectIconOverride = typeof ProjectIconOverride.Type; +/** + * Before v2, servers sent a monogram as a lucide icon carrying its text, so + * older clients showed a folder. Icons stored then, and v2 servers released + * before this change, still use that shape; it is read here, never written. + */ +const ProjectLucideIconWithLegacyMonogram = Schema.Struct({ + ...ProjectLucideIcon.fields, + monogramText: Schema.optional(ProjectMonogramText), + monogram: Schema.optional(ProjectMonogramText), +}); +const projectIconMembers = [ + ProjectLucideIconWithLegacyMonogram, + ProjectEmojiIcon, + ProjectMonogramIcon, +] as const; +type ProjectIconMember = (typeof projectIconMembers)[number]["Type"]; + +const fromLegacyMonogram = (icon: ProjectIconMember): ProjectIconOverride => { + if (icon.kind !== "lucide") return icon; + const text = icon.monogramText ?? icon.monogram; + return text === undefined + ? { kind: "lucide", name: icon.name, color: icon.color } + : { kind: "monogram", text, color: icon.color }; +}; + +/** An icon the server stores; it reads legacy monograms and writes the plain shape. */ +export const StoredProjectIcon = Schema.Union(projectIconMembers).pipe( + Schema.decodeTo( + Schema.toType(ProjectIconOverride), + SchemaTransformation.transform({ + decode: fromLegacyMonogram, + encode: (icon) => icon, + }), + ), +); + +/** + * An icon as clients receive it. A kind from a newer server decodes as no + * override, so the client shows the project's default icon. + */ +export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "kind").pipe( + Schema.decodeTo( + Schema.NullOr(Schema.toType(ProjectIconOverride)), + SchemaTransformation.transform< + ProjectIconOverride | null, + ProjectIconMember | UnknownUnionMember<"kind"> + >({ + decode: (icon) => (isUnknownUnionMember(icon) ? null : fromLegacyMonogram(icon)), + encode: (icon) => icon as ProjectIconMember, + }), + ), +); + export const Project = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), // Opt-in because background sync performs network I/O and may move the checkout.