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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function makeSnapshot(input: {
title: "Project",
workspaceRoot: input.workspaceRoot,
defaultModel: null,
iconPath: null,
scripts: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ const makeProjectionOverviewQuery = Effect.gen(function* () {
p.workspace_root AS "workspaceRoot",
p.default_model AS "defaultModel",
p.default_model_selection AS "defaultModelSelection",
p.icon_path AS "iconPath",
p.scripts_json AS "scripts",
p.created_at AS "createdAt",
p.updated_at AS "updatedAt",
Expand Down Expand Up @@ -364,6 +365,7 @@ const makeProjectionOverviewQuery = Effect.gen(function* () {
workspaceRoot: row.workspaceRoot,
defaultModel: row.defaultModel,
defaultModelSelection: row.defaultModelSelection,
iconPath: row.iconPath,
scripts: row.scripts,
activeThreadCount: row.activeThreadCount,
createdAt: row.createdAt,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
workspaceRoot: event.payload.workspaceRoot,
defaultModel: event.payload.defaultModel,
defaultModelSelection: event.payload.defaultModelSelection,
iconPath: event.payload.iconPath,
scripts: event.payload.scripts,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
Expand All @@ -425,6 +426,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
...(event.payload.defaultModelSelection !== undefined
? { defaultModelSelection: event.payload.defaultModelSelection }
: {}),
...(event.payload.iconPath !== undefined ? { iconPath: event.payload.iconPath } : {}),
...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
updatedAt: event.payload.updatedAt,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
title,
workspace_root,
default_model,
default_model_selection,
icon_path,
scripts_json,
created_at,
updated_at,
Expand All @@ -45,6 +47,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
'Project 1',
'/tmp/project-1',
'gpt-5-codex',
NULL,
NULL,
'[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
'2026-02-24T00:00:00.000Z',
'2026-02-24T00:00:01.000Z',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
workspace_root AS "workspaceRoot",
default_model AS "defaultModel",
default_model_selection AS "defaultModelSelection",
icon_path AS "iconPath",
scripts_json AS "scripts",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down Expand Up @@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
workspaceRoot: row.workspaceRoot,
defaultModel: row.defaultModel,
defaultModelSelection: row.defaultModelSelection,
iconPath: row.iconPath,
scripts: row.scripts,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/orchestration/commandInvariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const readModel: OrchestrationReadModel = {
title: "Project A",
workspaceRoot: "/tmp/project-a",
defaultModel: "gpt-5-codex",
iconPath: null,
scripts: [],
createdAt: now,
updatedAt: now,
Expand All @@ -40,6 +41,7 @@ const readModel: OrchestrationReadModel = {
title: "Project B",
workspaceRoot: "/tmp/project-b",
defaultModel: "gpt-5-codex",
iconPath: null,
scripts: [],
createdAt: now,
updatedAt: now,
Expand All @@ -50,6 +52,7 @@ const readModel: OrchestrationReadModel = {
title: "Project Archived",
workspaceRoot: "/tmp/project-archived",
defaultModel: "gpt-5-codex",
iconPath: null,
scripts: [],
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -182,6 +185,7 @@ describe("commandInvariants", () => {
type: "project.meta.update",
commandId: CommandId.makeUnsafe("cmd-project-update"),
projectId: ProjectId.makeUnsafe("project-a"),
iconPath: null,
},
projectId: ProjectId.makeUnsafe("project-a"),
}),
Expand All @@ -195,6 +199,7 @@ describe("commandInvariants", () => {
type: "project.meta.update",
commandId: CommandId.makeUnsafe("cmd-project-update-archived"),
projectId: ProjectId.makeUnsafe("project-archived"),
iconPath: null,
},
projectId: ProjectId.makeUnsafe("project-archived"),
}),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/decider.limits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeProject(input: {
title: input.id,
workspaceRoot: `/tmp/${input.id}`,
defaultModel: "gpt-5-codex",
iconPath: null,
scripts: [],
createdAt: input.updatedAt,
updatedAt: input.updatedAt,
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/orchestration/decider.projectScripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe("decider project scripts", () => {
projectId: asProjectId("project-scripts"),
title: "Scripts",
workspaceRoot: "/tmp/scripts",
iconPath: null,
createdAt: now,
},
readModel,
Expand Down Expand Up @@ -62,6 +63,7 @@ describe("decider project scripts", () => {
title: "Scripts",
workspaceRoot: "/tmp/scripts-populated",
scripts: Array.from(scripts),
iconPath: null,
createdAt: now,
},
readModel,
Expand Down Expand Up @@ -93,6 +95,8 @@ describe("decider project scripts", () => {
title: "Scripts",
workspaceRoot: "/tmp/scripts",
defaultModel: null,
defaultModelSelection: null,
iconPath: null,
scripts: [],
createdAt: now,
updatedAt: now,
Expand All @@ -117,6 +121,7 @@ describe("decider project scripts", () => {
commandId: CommandId.makeUnsafe("cmd-project-update-scripts"),
projectId: asProjectId("project-scripts"),
scripts: Array.from(scripts),
iconPath: null,
},
readModel,
}),
Expand All @@ -127,6 +132,52 @@ describe("decider project scripts", () => {
expect((event.payload as { scripts?: unknown[] }).scripts).toEqual(scripts);
});

it("propagates iconPath in project.meta.update payload", async () => {
const now = new Date().toISOString();
const initial = createEmptyReadModel(now);
const readModel = await Effect.runPromise(
projectEvent(initial, {
sequence: 1,
eventId: asEventId("evt-project-create-icon"),
aggregateKind: "project",
aggregateId: asProjectId("project-icon"),
type: "project.created",
occurredAt: now,
commandId: CommandId.makeUnsafe("cmd-project-create-icon"),
causationEventId: null,
correlationId: CommandId.makeUnsafe("cmd-project-create-icon"),
metadata: {},
payload: {
projectId: asProjectId("project-icon"),
title: "Icon",
workspaceRoot: "/tmp/icon",
defaultModel: null,
defaultModelSelection: null,
iconPath: null,
scripts: [],
createdAt: now,
updatedAt: now,
},
}),
);

const result = await Effect.runPromise(
decideOrchestrationCommand({
command: {
type: "project.meta.update",
commandId: CommandId.makeUnsafe("cmd-project-update-icon"),
projectId: asProjectId("project-icon"),
iconPath: "public/brand.svg",
},
readModel,
}),
);

const event = Array.isArray(result) ? result[0] : result;
expect(event.type).toBe("project.meta-updated");
expect((event.payload as { iconPath?: string | null }).iconPath).toBe("public/brand.svg");
});

it("emits user message and turn-start-requested events for thread.turn.start", async () => {
const now = new Date().toISOString();
const initial = createEmptyReadModel(now);
Expand All @@ -147,6 +198,8 @@ describe("decider project scripts", () => {
title: "Project",
workspaceRoot: "/tmp/project",
defaultModel: null,
defaultModelSelection: null,
iconPath: null,
scripts: [],
createdAt: now,
updatedAt: now,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
undefined,
)
: null),
iconPath: command.iconPath ?? null,
scripts: command.scripts ?? [],
createdAt: command.createdAt,
updatedAt: command.createdAt,
Expand Down Expand Up @@ -206,6 +207,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
),
}
: {}),
...(command.iconPath !== undefined ? { iconPath: command.iconPath } : {}),
...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
updatedAt: occurredAt,
},
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export function projectEvent(
workspaceRoot: payload.workspaceRoot,
defaultModel: payload.defaultModel,
defaultModelSelection: payload.defaultModelSelection,
iconPath: payload.iconPath,
scripts: payload.scripts,
createdAt: payload.createdAt,
updatedAt: payload.updatedAt,
Expand Down Expand Up @@ -228,6 +229,7 @@ export function projectEvent(
),
}
: {}),
...(payload.iconPath !== undefined ? { iconPath: payload.iconPath } : {}),
...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
updatedAt: payload.updatedAt,
}
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/persistence/Layers/ProjectionProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
defaultModelSelection: Schema.NullOr(
Schema.fromJsonString(ProjectionProject.fields.defaultModelSelection),
),
iconPath: Schema.NullOr(Schema.String),
scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
}),
);
Expand All @@ -43,6 +44,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root,
default_model,
default_model_selection,
icon_path,
scripts_json,
created_at,
updated_at,
Expand All @@ -54,6 +56,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
${row.workspaceRoot},
${row.defaultModel},
${row.defaultModelSelection},
${row.iconPath},
${row.scripts},
${row.createdAt},
${row.updatedAt},
Expand All @@ -65,6 +68,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root = excluded.workspace_root,
default_model = excluded.default_model,
default_model_selection = excluded.default_model_selection,
icon_path = excluded.icon_path,
scripts_json = excluded.scripts_json,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
Expand All @@ -83,6 +87,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root AS "workspaceRoot",
default_model AS "defaultModel",
default_model_selection AS "defaultModelSelection",
icon_path AS "iconPath",
scripts_json AS "scripts",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand All @@ -103,6 +108,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root AS "workspaceRoot",
default_model AS "defaultModel",
default_model_selection AS "defaultModelSelection",
icon_path AS "iconPath",
scripts_json AS "scripts",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand All @@ -125,6 +131,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
upsertProjectionProjectRow({
...row,
defaultModelSelection: row.defaultModelSelection ?? null,
iconPath: row.iconPath ?? null,
}).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
Expand Down
8 changes: 2 additions & 6 deletions apps/server/src/persistence/Layers/ProjectionThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ import {
// selection will have a NULL model_selection column.
const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
Struct.assign({
modelSelection: Schema.NullOr(
Schema.fromJsonString(ProjectionThread.fields.modelSelection),
),
modelSelection: Schema.NullOr(Schema.fromJsonString(ProjectionThread.fields.modelSelection)),
}),
);

Expand Down Expand Up @@ -144,9 +142,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
upsertProjectionThreadRow({
...row,
modelSelection: row.modelSelection ?? null,
}).pipe(
Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")),
);
}).pipe(Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")));

const getById: ProjectionThreadRepositoryShape["getById"] = (input) =>
getProjectionThreadRow(input).pipe(
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import Migration0021 from "./Migrations/021_ProjectionPendingUserInputs.ts";
import Migration0022 from "./Migrations/022_DecisionWorkspace.ts";
import Migration0023 from "./Migrations/023_ProjectionPendingUserInputsBackfill.ts";
import Migration0024 from "./Migrations/024_OpenclawGatewayConfig.ts";
import Migration0025 from "./Migrations/025_ProjectionProjectIconPath.ts";
import { Effect } from "effect";

/**
Expand Down Expand Up @@ -73,6 +74,7 @@ const loader = Migrator.fromRecord({
"22_DecisionWorkspace": Migration0022,
"23_ProjectionPendingUserInputsBackfill": Migration0023,
"24_OpenclawGatewayConfig": Migration0024,
"25_ProjectionProjectIconPath": Migration0025,
});

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as Effect from "effect/Effect";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* sql`
ALTER TABLE projection_projects
ADD COLUMN icon_path TEXT
`;
});
1 change: 1 addition & 0 deletions apps/server/src/persistence/Services/ProjectionProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const ProjectionProject = Schema.Struct({
workspaceRoot: Schema.String,
defaultModel: Schema.NullOr(Schema.String),
defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
iconPath: Schema.optional(Schema.NullOr(Schema.String)),
scripts: Schema.Array(ProjectScript),
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/projectFaviconRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ describe("tryHandleProjectFaviconRequest", () => {
});
});

it("serves an explicit icon override when provided", async () => {
const projectDir = makeTempDir("okcode-favicon-route-override-");
const iconPath = path.join(projectDir, "public", "brand", "override.svg");
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
fs.writeFileSync(iconPath, "<svg>override</svg>", "utf8");

await withRouteServer(async (baseUrl) => {
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&icon=${encodeURIComponent("public/brand/override.svg")}`;
const response = await request(baseUrl, pathname);
expect(response.statusCode).toBe(200);
expect(response.contentType).toContain("image/svg+xml");
expect(response.body).toBe("<svg>override</svg>");
});
});

it("resolves icon href from source files when no well-known favicon exists", async () => {
const projectDir = makeTempDir("okcode-favicon-route-source-");
const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
Expand Down
Loading
Loading