Skip to content

Commit d97b31a

Browse files
Merge branch 'main' into claude/unify-agent-tools-p10
2 parents c6d283e + 7a70a42 commit d97b31a

7 files changed

Lines changed: 134 additions & 17 deletions

File tree

apps/backend/src/chat/cardImages/metadata.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import { createHash } from "node:crypto";
2+
import type { CardTextSide } from "../../cards";
23
import type { GeneratedCardImageOperationMetadata } from "./types";
34

45
const generatedCardImageOperationNamespace = "flashcards-open-source-app:generated-card-image:v1";
6+
const generatedCardImageRequestBucketMs = 3_600_000;
7+
8+
export type GeneratedCardImageRequestContent = Readonly<{
9+
workspaceId: string;
10+
cardId: string;
11+
targetSide: CardTextSide;
12+
imagePrompt: string;
13+
altText: string;
14+
requestedAtMs: number;
15+
}>;
516

617
function deterministicUuidFromOperationIdentity(
718
purpose: "operation" | "media-asset",
8-
runId: string,
9-
operationKey: string,
19+
identity: ReadonlyArray<string | number>,
1020
): string {
1121
const digest = createHash("sha256")
1222
.update(JSON.stringify(
13-
[generatedCardImageOperationNamespace, purpose, runId.toLowerCase(), operationKey],
23+
[generatedCardImageOperationNamespace, purpose, ...identity],
1424
))
1525
.digest();
1626
const uuidBytes = Buffer.from(digest.subarray(0, 16));
@@ -27,15 +37,49 @@ function deterministicUuidFromOperationIdentity(
2737
].join("-");
2838
}
2939

30-
export function deriveGeneratedCardImageOperationMetadata(
31-
runId: string,
32-
operationKey: string,
40+
function deriveOperationMetadataFromIdentity(
41+
identityKind: GeneratedCardImageOperationMetadata["identityKind"],
42+
identity: ReadonlyArray<string | number>,
3343
): GeneratedCardImageOperationMetadata {
34-
const operationId = deterministicUuidFromOperationIdentity("operation", runId, operationKey);
44+
const operationId = deterministicUuidFromOperationIdentity("operation", identity);
3545
return {
46+
identityKind,
3647
operationId,
37-
mediaAssetId: deterministicUuidFromOperationIdentity("media-asset", runId, operationKey),
48+
mediaAssetId: deterministicUuidFromOperationIdentity("media-asset", identity),
3849
mediaLastOperationId: `generated-card-image:${operationId}:media`,
3950
cardLastOperationId: `generated-card-image:${operationId}:card`,
4051
};
4152
}
53+
54+
export function deriveGeneratedCardImageOperationMetadata(
55+
runId: string,
56+
operationKey: string,
57+
): GeneratedCardImageOperationMetadata {
58+
return deriveOperationMetadataFromIdentity("chat_run", [runId.toLowerCase(), operationKey]);
59+
}
60+
61+
/**
62+
* Identity for surfaces without a chat run. `requestedAtMs` is the backend clock at request
63+
* receipt, bucketed into epoch-aligned UTC hours: a literal repeat inside the hour is the same
64+
* operation, while a retry that lands in the next hour is a new, separately paid operation.
65+
*/
66+
export function deriveRequestContentGeneratedCardImageOperationMetadata(
67+
request: GeneratedCardImageRequestContent,
68+
): GeneratedCardImageOperationMetadata {
69+
if (!Number.isSafeInteger(request.requestedAtMs) || request.requestedAtMs < 0) {
70+
throw new RangeError(
71+
"Generated card image requestedAtMs must be a non-negative epoch-millisecond safe integer.",
72+
);
73+
}
74+
const contentSha256 = createHash("sha256")
75+
.update(JSON.stringify([request.imagePrompt, request.altText]))
76+
.digest("hex");
77+
return deriveOperationMetadataFromIdentity("request_content", [
78+
"request-content",
79+
request.workspaceId.toLowerCase(),
80+
request.cardId.toLowerCase(),
81+
request.targetSide,
82+
contentSha256,
83+
Math.floor(request.requestedAtMs / generatedCardImageRequestBucketMs),
84+
]);
85+
}

apps/backend/src/chat/cardImages/operation.postgres.integration.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ test("generated image operation reconciles ambiguous enqueue without early card
246246
let stagedObject: GeneratedMediaStagingObject | null = null;
247247
const dependencies = createGeneratedCardImageOperationDependencies({
248248
markProviderStartedFn: markGeneratedCardImageProviderStarted,
249+
markGeneratedMediaProviderStartedObjectFn: async () => {
250+
throw new Error("Chat operations must not write the storage provider-started marker.");
251+
},
249252
generateProviderImageFn: async () => {
250253
providerCalls += 1;
251254
providerStarted.resolve();
@@ -443,6 +446,9 @@ test("persisted provider start blocks replay without staging and permits staged
443446
providerStartCalls += 1;
444447
return markGeneratedCardImageProviderStarted(params);
445448
},
449+
markGeneratedMediaProviderStartedObjectFn: async () => {
450+
throw new Error("Chat operations must not write the storage provider-started marker.");
451+
},
446452
generateProviderImageFn: async () => {
447453
providerCalls += 1;
448454
throw new Error("Provider must not run after a durable provider start.");

apps/backend/src/chat/cardImages/operation.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ test("provider-start commit-unknown never crosses the provider boundary", async
167167
markProviderStartedFn: async () => {
168168
throw commitUnknownError;
169169
},
170+
markGeneratedMediaProviderStartedObjectFn: async () => {
171+
throw new Error("Chat operations must not write the storage provider-started marker.");
172+
},
170173
generateProviderImageFn: async () => {
171174
providerCallCount += 1;
172175
throw new Error("Provider must not run after an ambiguous start fence.");
@@ -202,6 +205,9 @@ test("previously-started provider state without staging is authoritative", async
202205
let providerCallCount = 0;
203206
const createdDependencies = createGeneratedCardImageOperationDependencies({
204207
markProviderStartedFn: async () => ({ status: "previously_started" }),
208+
markGeneratedMediaProviderStartedObjectFn: async () => {
209+
throw new Error("Chat operations must not write the storage provider-started marker.");
210+
},
205211
generateProviderImageFn: async () => {
206212
providerCallCount += 1;
207213
throw new Error("Provider must not replay a previously-started operation.");
@@ -244,6 +250,9 @@ test("pre-provider staging lookup failure remains safely retryable", async () =>
244250
providerStartCallCount += 1;
245251
return { status: "first_started" };
246252
},
253+
markGeneratedMediaProviderStartedObjectFn: async () => {
254+
throw new Error("Chat operations must not write the storage provider-started marker.");
255+
},
247256
generateProviderImageFn: async () => {
248257
providerCallCount += 1;
249258
return {
@@ -295,6 +304,9 @@ test("post-provider staging failure is authoritative and preserves its cause", a
295304
providerStartCallCount += 1;
296305
return { status: "first_started" };
297306
},
307+
markGeneratedMediaProviderStartedObjectFn: async () => {
308+
throw new Error("Chat operations must not write the storage provider-started marker.");
309+
},
298310
generateProviderImageFn: async () => {
299311
providerCallCount += 1;
300312
return {

apps/backend/src/chat/cardImages/operation.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DatabaseCommitOutcomeUnknownError } from "../../database/transient";
77
import { normalizeImageBytesForCard } from "../../mediaAssets/ingestion/imageNormalization";
88
import {
99
loadGeneratedMediaStagingObject,
10+
markGeneratedMediaProviderStartedObject,
1011
storeGeneratedMediaStagingObject,
1112
type GeneratedMediaStagingObject,
1213
} from "../../mediaAssets/storage";
@@ -76,6 +77,7 @@ export type GeneratedCardImageExternalDependencies = Readonly<{
7677
markProviderStartedFn: (
7778
params: MarkGeneratedCardImageProviderStartedParams,
7879
) => Promise<MarkGeneratedCardImageProviderStartedResult>;
80+
markGeneratedMediaProviderStartedObjectFn: typeof markGeneratedMediaProviderStartedObject;
7981
generateProviderImageFn: (input: OpenAIImageGenerationInput) => Promise<GeneratedProviderImage>;
8082
normalizeImageBytesForCardFn: typeof normalizeImageBytesForCard;
8183
loadGeneratedMediaStagingObjectFn: typeof loadGeneratedMediaStagingObject;
@@ -213,15 +215,19 @@ async function prepareStagedGeneratedCardImage(
213215
};
214216
const existing = await dependencies.loadGeneratedMediaStagingObjectFn(stagingInput);
215217
if (existing !== null) return { ...existing, reused: true };
216-
const providerStart = await dependencies.markProviderStartedFn({
217-
userId: input.userId,
218-
workspaceId: input.workspaceId,
219-
runId: input.runId,
220-
sessionId: input.sessionId,
221-
claimToken: input.claimToken,
222-
operationKey: input.operationKey,
223-
databaseDeadlineAtMs: input.operationDeadlineMs,
224-
});
218+
// The chat flag is set in the same transaction that asserts the run claim; a request-content
219+
// operation has no run, so its create-if-absent storage marker fences the paid call instead.
220+
const providerStart = operationMetadata.identityKind === "chat_run"
221+
? await dependencies.markProviderStartedFn({
222+
userId: input.userId,
223+
workspaceId: input.workspaceId,
224+
runId: input.runId,
225+
sessionId: input.sessionId,
226+
claimToken: input.claimToken,
227+
operationKey: input.operationKey,
228+
databaseDeadlineAtMs: input.operationDeadlineMs,
229+
})
230+
: await dependencies.markGeneratedMediaProviderStartedObjectFn(stagingInput);
225231
if (providerStart.status === "previously_started") {
226232
throw new GeneratedCardImageProviderOutcomeUnknownError(
227233
input.runId,
@@ -396,6 +402,7 @@ export async function generateCardImageWithDependencies(
396402

397403
const defaultExternalDependencies: GeneratedCardImageExternalDependencies = {
398404
markProviderStartedFn: markGeneratedCardImageProviderStarted,
405+
markGeneratedMediaProviderStartedObjectFn: markGeneratedMediaProviderStartedObject,
399406
generateProviderImageFn: async (input) => createOpenAIGeneratedCardImageProvider().generate(input),
400407
normalizeImageBytesForCardFn: normalizeImageBytesForCard,
401408
loadGeneratedMediaStagingObjectFn: loadGeneratedMediaStagingObject,

apps/backend/src/chat/cardImages/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type GeneratedCardImageResult = Readonly<{
3232
}>;
3333

3434
export type GeneratedCardImageOperationMetadata = Readonly<{
35+
identityKind: "chat_run" | "request_content";
3536
operationId: string;
3637
mediaAssetId: string;
3738
mediaLastOperationId: string;

apps/backend/src/mediaAssets/storage/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,16 @@ export {
5252
GeneratedMediaPromotionStorageTransientError,
5353
loadGeneratedMediaStagingObject,
5454
loadGeneratedMediaStagingObjectWithDependencies,
55+
markGeneratedMediaProviderStartedObject,
56+
markGeneratedMediaProviderStartedObjectWithDependencies,
5557
promoteGeneratedMediaObject,
5658
promoteGeneratedMediaObjectWithDependencies,
5759
storeGeneratedMediaStagingObject,
5860
storeGeneratedMediaStagingObjectWithDependencies,
5961
} from "./promotion/generatedPromotion";
6062
export type {
6163
GeneratedMediaObjectPromotionInput,
64+
GeneratedMediaProviderStartedMarkerResult,
6265
GeneratedMediaStagingObject,
6366
GeneratedMediaStagingObjectInput,
6467
StoreGeneratedMediaStagingObjectInput,

apps/backend/src/mediaAssets/storage/promotion/generatedPromotion.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export type GeneratedMediaStagingObjectInput = GeneratedMediaStorageRequestInput
3030
export type GeneratedMediaStagingObject = Readonly<{
3131
stagingStorageKey: string; mimeType: typeof imageJpegCardMediaBlobMimeType; sizeBytes: number; sha256: string;
3232
}>;
33+
export type GeneratedMediaProviderStartedMarkerResult =
34+
| Readonly<{ status: "first_started" }>
35+
| Readonly<{ status: "previously_started" }>;
3336
export type StoreGeneratedMediaStagingObjectInput = GeneratedMediaStagingObjectInput & Readonly<{
3437
bytes: Buffer; mimeType: typeof imageJpegCardMediaBlobMimeType; sizeBytes: number; sha256: string;
3538
}>;
@@ -350,6 +353,39 @@ export async function loadGeneratedMediaStagingObjectWithDependencies(
350353
);
351354
return metadata === null ? null : readStagingObject(input, metadata);
352355
}
356+
/**
357+
* Create-if-absent zero-byte sibling of the staging key, written once before a paid provider call.
358+
* The suffix keeps it off every staging key, whose leaf is a bare SHA-256 hex digest. A lost PUT
359+
* response retried here answers 412 and reads as previously_started, which refuses a second payment.
360+
*/
361+
export async function markGeneratedMediaProviderStartedObjectWithDependencies(
362+
input: GeneratedMediaStagingObjectInput,
363+
dependencies: MediaAssetStorageDependencies,
364+
): Promise<GeneratedMediaProviderStartedMarkerResult> {
365+
validateStagingIdentity(input);
366+
const markerStorageKey = `${buildMediaUploadStagingStorageKey(
367+
input.workspaceId, input.mediaAssetId, input.operationId,
368+
)}.provider-started`;
369+
try {
370+
await runS3(input, "put_object", async () => dependencies.s3Client.send(
371+
new PutObjectCommand({
372+
Bucket: dependencies.getMediaAssetsStorageConfigFn().bucketName,
373+
Key: markerStorageKey,
374+
Body: Buffer.alloc(0),
375+
IfNoneMatch: "*",
376+
}),
377+
{ abortSignal: input.signal },
378+
));
379+
return { status: "first_started" };
380+
} catch (error) {
381+
input.signal.throwIfAborted();
382+
const statusCode = getS3ErrorStatusCode(error);
383+
if (statusCode === 412) return { status: "previously_started" };
384+
if (error instanceof GeneratedMediaPromotionStorageTransientError) throw error;
385+
if (statusCode === null) throw error;
386+
terminal("S3_REQUEST_REJECTED", "Object storage rejected the provider-started marker request.", statusCode);
387+
}
388+
}
353389
export async function storeGeneratedMediaStagingObjectWithDependencies(
354390
input: StoreGeneratedMediaStagingObjectInput,
355391
dependencies: MediaAssetStorageDependencies,
@@ -439,6 +475,14 @@ export async function loadGeneratedMediaStagingObject(
439475
getMediaAssetsStorageConfigFn: getMediaAssetsStorageConfig,
440476
});
441477
}
478+
export async function markGeneratedMediaProviderStartedObject(
479+
input: GeneratedMediaStagingObjectInput,
480+
): Promise<GeneratedMediaProviderStartedMarkerResult> {
481+
return markGeneratedMediaProviderStartedObjectWithDependencies(input, {
482+
s3Client: getMediaAssetsS3Client(),
483+
getMediaAssetsStorageConfigFn: getMediaAssetsStorageConfig,
484+
});
485+
}
442486
export async function storeGeneratedMediaStagingObject(
443487
input: StoreGeneratedMediaStagingObjectInput,
444488
): Promise<GeneratedMediaStagingObject> {

0 commit comments

Comments
 (0)