Skip to content
Open
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
55 changes: 54 additions & 1 deletion apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { ThreadId } from "@t3tools/contracts";
import { EventId, ThreadId } from "@t3tools/contracts";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -206,6 +206,59 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(testLayer)),
);

it.effect("issues exact capabilities for provider-generated images", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-generated-image-",
});
const imagePath = path.join(root, "generated.png");
const siblingPath = path.join(root, "other.png");
yield* fileSystem.writeFile(imagePath, new Uint8Array([137, 80, 78, 71]));
yield* fileSystem.writeFile(siblingPath, new Uint8Array([137, 80, 78, 71]));
const canonicalImagePath = yield* fileSystem.realPath(imagePath);
const resource = {
_tag: "generated-image" as const,
threadId: ThreadId.make("thread-1"),
activityId: EventId.make("activity-1"),
};

const result = yield* issueAssetUrl({
resource,
generatedImagePath: imagePath,
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");
const token = suffix.slice(0, separatorIndex);

expect(yield* resolveAsset(token, "generated.png")).toEqual({
kind: "file",
path: canonicalImagePath,
});
expect(yield* resolveAsset(token, "other.png")).toBeNull();
expect(yield* resolveAsset(token, "../generated.png")).toBeNull();
}).pipe(Effect.provide(testLayer)),
);

it.effect("rejects generated-image resources without matching image paths", () =>
Effect.gen(function* () {
const resource = {
_tag: "generated-image" as const,
threadId: ThreadId.make("thread-1"),
activityId: EventId.make("activity-1"),
};
const missingPathError = yield* issueAssetUrl({ resource }).pipe(Effect.flip);
expect(missingPathError._tag).toBe("AssetGeneratedImageNotFoundError");

const invalidTypeError = yield* issueAssetUrl({
resource,
generatedImagePath: "/tmp/generated.txt",
}).pipe(Effect.flip);
expect(invalidTypeError._tag).toBe("AssetPreviewTypeValidationError");
}).pipe(Effect.provide(testLayer)),
);

it.effect("issues project favicon capabilities with a signed fallback", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
80 changes: 80 additions & 0 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { AssetResource } from "@t3tools/contracts";
import {
AssetAttachmentNotFoundError,
AssetGeneratedImageInspectionError,
AssetGeneratedImageNotFoundError,
AssetPreviewTypeValidationError,
AssetProjectFaviconInspectionError,
AssetProjectFaviconNotFoundError,
Expand Down Expand Up @@ -77,6 +79,12 @@ const AssetClaimsSchema = Schema.Union([
attachmentId: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("generated-image"),
absolutePath: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("project-favicon"),
Expand Down Expand Up @@ -162,9 +170,37 @@ const resolveCanonicalWorkspaceFileForRequest = (input: {
Effect.orElseSucceed(() => null),
);

const resolveCanonicalGeneratedImage = Effect.fn("AssetAccess.resolveCanonicalGeneratedImage")(
function* (absolutePath: string) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
if (!path.isAbsolute(absolutePath) || !isWorkspaceImagePreviewPath(absolutePath)) {
return null;
}
const canonicalPath = yield* optionOnNotFound(fileSystem.realPath(absolutePath));
if (Option.isNone(canonicalPath) || !isWorkspaceImagePreviewPath(canonicalPath.value)) {
return null;
}
const info = yield* optionOnNotFound(fileSystem.stat(canonicalPath.value));
return Option.isSome(info) && info.value.type === "File" ? canonicalPath.value : null;
},
);

const resolveCanonicalGeneratedImageForRequest = (absolutePath: string) =>
resolveCanonicalGeneratedImage(absolutePath).pipe(
Effect.tapError((cause) =>
Effect.logError("Failed to resolve generated image asset.", {
absolutePath,
cause,
}),
),
Effect.orElseSucceed(() => null),
);

export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
readonly resource: AssetResource;
readonly workspaceRoot?: string;
readonly generatedImagePath?: string;
}) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -272,6 +308,43 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
fileName = path.basename(attachmentPath);
break;
}
case "generated-image": {
if (!input.generatedImagePath) {
return yield* new AssetGeneratedImageNotFoundError({
resource: input.resource,
});
}
if (
!path.isAbsolute(input.generatedImagePath) ||
!isWorkspaceImagePreviewPath(input.generatedImagePath)
) {
return yield* new AssetPreviewTypeValidationError({
resource: input.resource,
});
}
const canonicalPath = yield* resolveCanonicalGeneratedImage(input.generatedImagePath).pipe(
Effect.mapError(
(cause) =>
new AssetGeneratedImageInspectionError({
resource: input.resource,
cause,
}),
),
);
if (!canonicalPath) {
return yield* new AssetGeneratedImageNotFoundError({
resource: input.resource,
});
}
claims = {
version: 1,
kind: "generated-image",
absolutePath: canonicalPath,
expiresAt,
};
fileName = path.basename(canonicalPath);
break;
}
case "project-favicon": {
const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe(
Effect.mapError(
Expand Down Expand Up @@ -400,6 +473,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
const decodedPath = decodeRelativePath(relativePath);
if (decodedPath === null) return null;
const path = yield* Path.Path;
if (claims.kind === "generated-image") {
if (decodedPath !== path.basename(claims.absolutePath)) return null;
const generatedImagePath = yield* resolveCanonicalGeneratedImageForRequest(claims.absolutePath);
return generatedImagePath
? ({ kind: "file", path: generatedImagePath } satisfies ResolvedAsset)
: null;
}
if (claims.kind === "workspace-file-exact") {
if (decodedPath !== path.basename(claims.relativePath)) return null;
const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
Expand Down
129 changes: 129 additions & 0 deletions apps/server/src/assets/GeneratedImageResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { EventId, type OrchestrationThreadActivity } from "@t3tools/contracts";
import { it as effectIt } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { describe, expect, it } from "vite-plus/test";

import {
findGeneratedImagePath,
isGeneratedImageFileLookupRetryable,
retryGeneratedImageFileLookup,
} from "./GeneratedImageResolver.ts";

const ACTIVITY_ID = EventId.make("activity-generated-image");

function activity(
item: Record<string, unknown>,
overrides: Partial<OrchestrationThreadActivity> = {},
): OrchestrationThreadActivity {
return {
id: ACTIVITY_ID,
tone: "tool",
kind: "tool.completed",
summary: "Image view",
payload: { data: { item } },
turnId: null,
createdAt: "2026-07-24T00:00:00.000Z",
...overrides,
};
}

describe("generated image resolution", () => {
it("reads the saved path from a completed image generation activity", () => {
const path = "/provider/session/generated.png";
expect(
findGeneratedImagePath(
[
activity({
type: "imageGeneration",
status: "completed",
savedPath: path,
}),
],
ACTIVITY_ID,
),
).toBe(path);
});

it("rejects non-generation, incomplete, and mismatched activities", () => {
expect(
findGeneratedImagePath(
[activity({ type: "imageView", status: "completed", path: "/tmp/viewed.png" })],
ACTIVITY_ID,
),
).toBeNull();
expect(
findGeneratedImagePath(
[
activity({
type: "imageGeneration",
status: "inProgress",
savedPath: "/tmp/generated.png",
}),
],
ACTIVITY_ID,
),
).toBeNull();
expect(
findGeneratedImagePath(
[
activity(
{
type: "imageGeneration",
status: "completed",
savedPath: "/tmp/generated.png",
},
{ kind: "tool.updated" },
),
],
ACTIVITY_ID,
),
).toBeNull();
expect(
findGeneratedImagePath(
[
activity({
type: "imageGeneration",
status: "completed",
savedPath: "/tmp/generated.png",
}),
],
EventId.make("activity-other"),
),
).toBeNull();
});

effectIt.live("retries the projection lookup until the generated image appears", () =>
Effect.gen(function* () {
let attempts = 0;
const result = yield* retryGeneratedImageFileLookup(
Effect.suspend(() => {
attempts += 1;
const generatedImagePath = findGeneratedImagePath(
attempts < 3
? []
: [
activity({
type: "imageGeneration",
status: "completed",
savedPath: "/tmp/generated.png",
}),
],
ACTIVITY_ID,
);
return generatedImagePath
? Effect.succeed(generatedImagePath)
: Effect.fail({ _tag: "AssetGeneratedImageNotFoundError" as const });
}),
);

expect(result).toBe("/tmp/generated.png");
expect(attempts).toBe(3);
expect(
isGeneratedImageFileLookupRetryable({ _tag: "AssetGeneratedImageNotFoundError" }),
).toBe(true);
expect(
isGeneratedImageFileLookupRetryable({ _tag: "AssetGeneratedImageInspectionError" }),
).toBe(false);
}),
);
});
52 changes: 52 additions & 0 deletions apps/server/src/assets/GeneratedImageResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { EventId, OrchestrationThreadActivity } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";

const GENERATED_IMAGE_LOOKUP_RETRY_COUNT = 20;
const GENERATED_IMAGE_LOOKUP_RETRY_INTERVAL = "250 millis";

function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}

export function findGeneratedImagePath(
activities: ReadonlyArray<OrchestrationThreadActivity>,
activityId: EventId,
): string | null {
const activity = activities.find((candidate) => candidate.id === activityId);
if (!activity || activity.kind !== "tool.completed") {
return null;
}

const payload = asRecord(activity.payload);
const data = asRecord(payload?.data);
const item = asRecord(data?.item);
if (
item?.type !== "imageGeneration" ||
item.status !== "completed" ||
typeof item.savedPath !== "string"
) {
return null;
}

const savedPath = item.savedPath.trim();
return savedPath.length > 0 ? savedPath : null;
}

export function isGeneratedImageFileLookupRetryable(error: unknown): boolean {
return asRecord(error)?._tag === "AssetGeneratedImageNotFoundError";
}

export function retryGeneratedImageFileLookup<A, E, R>(
lookup: Effect.Effect<A, E, R>,
): Effect.Effect<A, E, R> {
return lookup.pipe(
Effect.retry({
times: GENERATED_IMAGE_LOOKUP_RETRY_COUNT,
schedule: Schedule.spaced(GENERATED_IMAGE_LOOKUP_RETRY_INTERVAL),
while: isGeneratedImageFileLookupRetryable,
}),
);
Comment thread
colonelpanic8 marked this conversation as resolved.
}
Loading
Loading