Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/server/src/orchestration-v2/ProjectStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
IsoDateTime,
ModelSelection,
type OrchestrationProjectShell,
ProjectIconOverride,
StoredProjectIcon,
ProjectId,
ProjectScript,
ThreadEnvMode,
Expand Down Expand Up @@ -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,
Expand All @@ -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)),
});

Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/persistence/Layers/OrchestrationEventStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
OrchestrationV2DomainEventJson,
OrchestrationV2StoredEvent,
ProjectId,
ProjectIconOverride,
StoredProjectIcon,
ThreadId,
type OrchestrationV2DomainEvent,
} from "@t3tools/contracts";
Expand All @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions packages/contracts/src/applicationEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
Expand Down
4 changes: 2 additions & 2 deletions packages/contracts/src/orchestrationProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand Down
96 changes: 34 additions & 62 deletions packages/contracts/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { OrchestrationProjectShell } from "./orchestrationProject.ts";

import {
ProjectFaviconPath,
ProjectIconOverride,
ReceivedProjectIcon,
StoredProjectIcon,
ProjectReadFileError,
ProjectCreatePayload,
ProjectUpdatePayload,
Expand Down Expand Up @@ -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");
}),
);
88 changes: 57 additions & 31 deletions packages/contracts/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,58 +92,81 @@ 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),
Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i),
);
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(
]);
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(
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,
Schema.toType(ProjectIconOverride),
SchemaTransformation.transform<ProjectIconOverride, ProjectIconMember>({
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 type ProjectIconOverride = typeof ProjectIconOverride.Type;

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.
Expand Down
Loading