Skip to content

Commit 5747ed8

Browse files
author
Sprite
committed
Bind media and imports to originating project
1 parent 7f8d4ce commit 5747ed8

6 files changed

Lines changed: 44 additions & 5 deletions

File tree

packages/websocket/src/http-api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ export async function handleWorkflowRun(
638638
// cold-start bootstrap failure must not turn that into a 500.
639639
environment: () => getWorkflowRuntimeEnvironment(options),
640640
params: body?.params ?? {},
641+
projectId: body?.project_id ?? null,
641642
background: body?.background ?? false,
642643
interactive: body?.interactive === true,
643644
// The server's own import site, so a test that mocks it still governs.
@@ -1179,13 +1180,19 @@ export async function handleWorkflowImportBundle(
11791180
return errorResponse(405, "Method not allowed");
11801181
}
11811182
const userId = getUserId(request, options.userIdHeader ?? "x-user-id");
1183+
let projectId =
1184+
new URL(request.url).searchParams.get("project_id") ?? "default";
11821185

11831186
let zipBytes: Uint8Array | null = null;
11841187
const contentType = request.headers.get("content-type") ?? "";
11851188
if (contentType.toLowerCase().includes("multipart/form-data")) {
11861189
try {
11871190
const fd = await request.formData();
11881191
const file = fd.get("file") as File | null;
1192+
const formProjectId = fd.get("project_id");
1193+
if (typeof formProjectId === "string" && formProjectId) {
1194+
projectId = formProjectId;
1195+
}
11891196
if (file) {
11901197
zipBytes = new Uint8Array(await file.arrayBuffer());
11911198
}
@@ -1201,6 +1208,13 @@ export async function handleWorkflowImportBundle(
12011208
if (!zipBytes) {
12021209
return errorResponse(400, "A .nodetool bundle file is required");
12031210
}
1211+
if (projectId !== "default") {
1212+
try {
1213+
await Project.requireOwned(userId, projectId);
1214+
} catch {
1215+
return errorResponse(400, "Project not found");
1216+
}
1217+
}
12041218

12051219
let result: Awaited<ReturnType<typeof importWorkflowBundle>>;
12061220
try {
@@ -1211,6 +1225,7 @@ export async function handleWorkflowImportBundle(
12111225
name: fileName,
12121226
content_type: assetType,
12131227
parent_id: userId,
1228+
project_id: projectId,
12141229
size: bytes.byteLength
12151230
})) as Asset;
12161231
const storedName = getAssetFileName(asset.id, asset.content_type);
@@ -1623,6 +1638,7 @@ export async function handleExtractAudio(
16231638
content_type: "audio/wav",
16241639
parent_id: source.id,
16251640
workflow_id: source.workflow_id ?? null,
1641+
project_id: source.project_id,
16261642
node_id: null,
16271643
job_id: null,
16281644
metadata: null,

packages/websocket/src/http-body-schemas.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export const workflowRunBodySchema = z.object({
8282
interactive: unchecked<boolean>(),
8383
max_decisions: lenientNumber(),
8484
max_retries_per_node: lenientNumber(),
85-
decision_timeout_ms: lenientNumber()
85+
decision_timeout_ms: lenientNumber(),
86+
project_id: lenientString()
8687
});
8788

8889
/** `POST /api/debug/sessions/:id/verdict` */

packages/websocket/src/session/chat-turn.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,8 @@ export class ChatTurnHandler {
602602
const asset = new Asset({
603603
user_id: userId,
604604
workflow_id: workflowId ?? null,
605+
project_id:
606+
(await Project.findByThread(userId, threadId))?.id ?? "default",
605607
name: `image_${Date.now()}`,
606608
content_type: mimeType,
607609
// Home — see the chat media generation path.
@@ -2731,6 +2733,7 @@ export class ChatTurnHandler {
27312733
const threadId = isString(data.thread_id) ? data.thread_id : "";
27322734
const workflowId = isString(data.workflow_id) ? data.workflow_id : null;
27332735
const userId = this.session.requireUserId();
2736+
const projectId = (await Project.findByThread(userId, threadId))?.id ?? null;
27342737
const mode = String(mediaGeneration.mode ?? "");
27352738
// The media composer's own selection first; a client without a separate
27362739
// media picker (mobile) sends only the message-level one. The built-in
@@ -2825,6 +2828,7 @@ export class ChatTurnHandler {
28252828
origin: { surface: "chat", thread_id: threadId || null },
28262829
threadId,
28272830
workflowId: workflowId ?? null,
2831+
projectId,
28282832
assetNamePrefix: mode,
28292833
signal
28302834
});

packages/websocket/src/session/commands.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -827,8 +827,14 @@ export class CommandRouter {
827827
const audioFormat = isString(data.audio_format)
828828
? (data.audio_format as string)
829829
: undefined;
830-
const capability = data.capability === "reference_to_video" ? "reference_to_video" : undefined;
831-
const referenceImages = Array.isArray(data.reference_images) ? data.reference_images : undefined;
830+
const capability =
831+
data.capability === "reference_to_video"
832+
? "reference_to_video"
833+
: undefined;
834+
const referenceImages = Array.isArray(data.reference_images)
835+
? data.reference_images
836+
: undefined;
837+
const projectId = isString(data.project_id) ? data.project_id : undefined;
832838
return this.runRpc(command, requestId, () =>
833839
inference.runDirectMediaGeneration({
834840
mode,
@@ -851,6 +857,7 @@ export class CommandRouter {
851857
audioFormat,
852858
capability,
853859
referenceImages,
860+
projectId,
854861
requestId
855862
})
856863
);

packages/websocket/src/session/inference.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
22

33
import { createLogger } from "@nodetool-ai/config";
44
import { getModelUnitPrice } from "@nodetool-ai/model-pricing";
5-
import { Asset, Prediction } from "@nodetool-ai/models";
5+
import { Asset, Prediction, Project } from "@nodetool-ai/models";
66
import { extractPricingParams } from "@nodetool-ai/node-sdk/pricing-params";
77
import { resolveNodetoolDelegate } from "@nodetool-ai/protocol";
88
import {
@@ -75,6 +75,8 @@ export interface DirectMediaGenerationRequest {
7575
requestId?: string;
7676
capability?: "reference_to_video";
7777
referenceImages?: unknown[];
78+
/** Project captured when the request was accepted. */
79+
projectId?: string | null;
7880
}
7981

8082
/**
@@ -507,6 +509,9 @@ export class DirectInferenceHandler {
507509
throw new Error("prompt is required");
508510
}
509511
const userId = this.session.requireUserId();
512+
if (req.projectId && req.projectId !== "default") {
513+
await Project.requireOwned(userId, req.projectId);
514+
}
510515
const provider = await this.session.resolveProvider(req.provider, userId);
511516
if (req.provider !== "nodetool") {
512517
// BYOK: the user's own keys, never metered.
@@ -608,6 +613,7 @@ export class DirectInferenceHandler {
608613
provider,
609614
origin: { surface: "rpc", request_id: req.requestId ?? null },
610615
workflowId: null,
616+
projectId: req.projectId ?? null,
611617
assetNamePrefix: req.mode,
612618
// The row names the RPC mode the way it always did.
613619
nodeType: () => `direct.${req.mode}`,

packages/websocket/src/session/media-generation.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export interface GenerationRunOptions {
4040
threadId?: string | null;
4141
/** Stamped on assets stored through {@link GenerationRun.storeAsset}. */
4242
workflowId?: string | null;
43+
/** Project captured when this generation was accepted. */
44+
projectId?: string | null;
4345
/** Prefixes a stored asset's name, e.g. `image_1730000000000`. */
4446
assetNamePrefix: string;
4547
signal?: AbortSignal;
@@ -92,6 +94,7 @@ export function createGenerationRun(
9294
origin,
9395
threadId = null,
9496
workflowId = null,
97+
projectId = null,
9598
assetNamePrefix,
9699
signal,
97100
nodeType,
@@ -102,7 +105,8 @@ export function createGenerationRun(
102105
const context = new GenerationContext({
103106
jobId: randomUUID(),
104107
userId,
105-
threadId: threadId || null
108+
threadId: threadId || null,
109+
projectId
106110
});
107111
context.registerProvider(providerId, provider);
108112
context.setModelInterfaces({ createAsset: createAssetModelInterface });
@@ -144,6 +148,7 @@ export function createGenerationRun(
144148
const asset = new Asset({
145149
user_id: userId,
146150
workflow_id: workflowId,
151+
project_id: projectId ?? "default",
147152
name: `${assetNamePrefix}_${Date.now()}`,
148153
content_type: contentType,
149154
// Home, the same folder an upload lands in. A null parent is

0 commit comments

Comments
 (0)