Merge the media job runtime into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
d54d8bff88
@ -25,6 +25,8 @@ import {
|
|||||||
createCloudAgentConnection,
|
createCloudAgentConnection,
|
||||||
createConnectorStack,
|
createConnectorStack,
|
||||||
createJobReconciler,
|
createJobReconciler,
|
||||||
|
createMediaOperations,
|
||||||
|
createMediaRuntime,
|
||||||
createMessagingContextLoader,
|
createMessagingContextLoader,
|
||||||
createMessagingTeamChatSender,
|
createMessagingTeamChatSender,
|
||||||
createProfileConsolidationScheduler,
|
createProfileConsolidationScheduler,
|
||||||
@ -47,6 +49,7 @@ import {
|
|||||||
isPipedreamEnabled,
|
isPipedreamEnabled,
|
||||||
LocalAgentHomeStore,
|
LocalAgentHomeStore,
|
||||||
LocalArtifactStore,
|
LocalArtifactStore,
|
||||||
|
loadMediaSettings,
|
||||||
McpConnector,
|
McpConnector,
|
||||||
McpOAuthBroker,
|
McpOAuthBroker,
|
||||||
messagingPlatformsFromEnv,
|
messagingPlatformsFromEnv,
|
||||||
@ -376,6 +379,15 @@ export async function createApp(
|
|||||||
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
|
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
|
||||||
});
|
});
|
||||||
const shutdown = new AbortController();
|
const shutdown = new AbortController();
|
||||||
|
const media = createMediaRuntime({
|
||||||
|
prisma,
|
||||||
|
events,
|
||||||
|
jobs,
|
||||||
|
artifacts,
|
||||||
|
registrations: createMediaOperations({
|
||||||
|
loadSettings: () => loadMediaSettings({ prisma, secrets }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
prisma,
|
prisma,
|
||||||
runtime,
|
runtime,
|
||||||
@ -412,6 +424,7 @@ export async function createApp(
|
|||||||
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
||||||
web: createWebProvider(),
|
web: createWebProvider(),
|
||||||
cloudAgent,
|
cloudAgent,
|
||||||
|
media,
|
||||||
shutdownSignal: shutdown.signal,
|
shutdownSignal: shutdown.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -429,6 +442,7 @@ export async function createApp(
|
|||||||
deploymentModelKey: env.deploymentModelKey,
|
deploymentModelKey: env.deploymentModelKey,
|
||||||
messaging,
|
messaging,
|
||||||
cloudAgent,
|
cloudAgent,
|
||||||
|
media,
|
||||||
});
|
});
|
||||||
if (inMemoryJobs) {
|
if (inMemoryJobs) {
|
||||||
await inMemoryJobs.start(jobHandlers);
|
await inMemoryJobs.start(jobHandlers);
|
||||||
@ -447,6 +461,7 @@ export async function createApp(
|
|||||||
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
||||||
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
||||||
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
||||||
|
reconcileMediaJobs: () => media.reconcile(),
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
reconciler?.start();
|
reconciler?.start();
|
||||||
|
|||||||
@ -33,6 +33,7 @@ const runTriggers = new Set<Run["trigger"]>([
|
|||||||
"messaging",
|
"messaging",
|
||||||
"cloud_agent",
|
"cloud_agent",
|
||||||
"delegation",
|
"delegation",
|
||||||
|
"media",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run {
|
function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run {
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
createCloudAgentConnection,
|
createCloudAgentConnection,
|
||||||
createConnectorStack,
|
createConnectorStack,
|
||||||
createJobReconciler,
|
createJobReconciler,
|
||||||
|
createMediaOperations,
|
||||||
|
createMediaRuntime,
|
||||||
createMessagingContextLoader,
|
createMessagingContextLoader,
|
||||||
createPostgresReconciliationLeadership,
|
createPostgresReconciliationLeadership,
|
||||||
createProfileConsolidationScheduler,
|
createProfileConsolidationScheduler,
|
||||||
@ -29,6 +31,7 @@ import {
|
|||||||
isPipedreamEnabled,
|
isPipedreamEnabled,
|
||||||
LocalAgentHomeStore,
|
LocalAgentHomeStore,
|
||||||
LocalArtifactStore,
|
LocalArtifactStore,
|
||||||
|
loadMediaSettings,
|
||||||
McpConnector,
|
McpConnector,
|
||||||
McpOAuthBroker,
|
McpOAuthBroker,
|
||||||
messagingEnvFromProcess,
|
messagingEnvFromProcess,
|
||||||
@ -164,6 +167,15 @@ async function main() {
|
|||||||
});
|
});
|
||||||
// One provider instance so emulator launches and polls share the same Map.
|
// One provider instance so emulator launches and polls share the same Map.
|
||||||
const cloudAgent = createCloudAgentConnection();
|
const cloudAgent = createCloudAgentConnection();
|
||||||
|
const media = createMediaRuntime({
|
||||||
|
prisma,
|
||||||
|
events,
|
||||||
|
jobs,
|
||||||
|
artifacts,
|
||||||
|
registrations: createMediaOperations({
|
||||||
|
loadSettings: () => loadMediaSettings({ prisma, secrets }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
prisma,
|
prisma,
|
||||||
runtime,
|
runtime,
|
||||||
@ -199,6 +211,7 @@ async function main() {
|
|||||||
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
||||||
web: createWebProvider(),
|
web: createWebProvider(),
|
||||||
cloudAgent,
|
cloudAgent,
|
||||||
|
media,
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobHandlers = createBackgroundJobHandlers({
|
const jobHandlers = createBackgroundJobHandlers({
|
||||||
@ -215,6 +228,7 @@ async function main() {
|
|||||||
deploymentModelKey,
|
deploymentModelKey,
|
||||||
messaging,
|
messaging,
|
||||||
cloudAgent,
|
cloudAgent,
|
||||||
|
media,
|
||||||
});
|
});
|
||||||
// graphile-worker run() connects through the shared pool. createPool already
|
// graphile-worker run() connects through the shared pool. createPool already
|
||||||
// retries connect() on 53300 a finite number of times. Keep retrying start
|
// retries connect() on 53300 a finite number of times. Keep retrying start
|
||||||
@ -244,6 +258,7 @@ async function main() {
|
|||||||
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
||||||
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
||||||
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
||||||
|
reconcileMediaJobs: () => media.reconcile(),
|
||||||
});
|
});
|
||||||
reconciler.start();
|
reconciler.start();
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import {
|
|||||||
dispatchBackgroundJob,
|
dispatchBackgroundJob,
|
||||||
historyCompactJob,
|
historyCompactJob,
|
||||||
historyCompactJobKey,
|
historyCompactJobKey,
|
||||||
|
MEDIA_JOB_QUEUE,
|
||||||
|
mediaRunJob,
|
||||||
|
mediaRunJobKey,
|
||||||
messagingDeliverJob,
|
messagingDeliverJob,
|
||||||
parseBackgroundJob,
|
parseBackgroundJob,
|
||||||
profileConsolidateJob,
|
profileConsolidateJob,
|
||||||
@ -22,6 +25,7 @@ function handlers(): BackgroundJobHandlers {
|
|||||||
"messaging.deliver": vi.fn(async () => undefined),
|
"messaging.deliver": vi.fn(async () => undefined),
|
||||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||||
"profile.consolidate": vi.fn(async () => undefined),
|
"profile.consolidate": vi.fn(async () => undefined),
|
||||||
|
"media.run": vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,3 +116,21 @@ describe("profileConsolidateJob", () => {
|
|||||||
expect(profileConsolidateJobKey("user-1")).not.toBe(profileConsolidateJobKey("user-2"));
|
expect(profileConsolidateJobKey("user-1")).not.toBe(profileConsolidateJobKey("user-2"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mediaRunJob", () => {
|
||||||
|
it("validates, dispatches, and runs every media job on the shared media queue", async () => {
|
||||||
|
const target = handlers();
|
||||||
|
await dispatchBackgroundJob(target, "media.run", { jobId: "job-1" });
|
||||||
|
expect(target["media.run"]).toHaveBeenCalledWith({ jobId: "job-1" });
|
||||||
|
expect(() => parseBackgroundJob("media.run", { jobId: "" })).toThrow();
|
||||||
|
expect(mediaRunJob("job-1")).toEqual({
|
||||||
|
name: "media.run",
|
||||||
|
payload: { jobId: "job-1" },
|
||||||
|
replaceKey: mediaRunJobKey("job-1"),
|
||||||
|
queue: MEDIA_JOB_QUEUE,
|
||||||
|
});
|
||||||
|
const at = new Date("2026-10-03T12:00:00.000Z");
|
||||||
|
expect(mediaRunJob("job-1", at).availableAt).toBe(at);
|
||||||
|
expect(mediaRunJobKey("job-1")).not.toBe(mediaRunJobKey("job-2"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -23,6 +23,7 @@ const payloadSchemas = {
|
|||||||
"messaging.deliver": z.object({ runId: z.string().min(1).optional() }),
|
"messaging.deliver": z.object({ runId: z.string().min(1).optional() }),
|
||||||
"cloud_agent.poll": z.object({ agentId: z.string().min(1) }),
|
"cloud_agent.poll": z.object({ agentId: z.string().min(1) }),
|
||||||
"profile.consolidate": z.object({ userId: z.string().min(1) }),
|
"profile.consolidate": z.object({ userId: z.string().min(1) }),
|
||||||
|
"media.run": z.object({ jobId: z.string().min(1) }),
|
||||||
} satisfies { [Name in BackgroundJobName]: z.ZodType<BackgroundJobPayloads[Name]> };
|
} satisfies { [Name in BackgroundJobName]: z.ZodType<BackgroundJobPayloads[Name]> };
|
||||||
|
|
||||||
export function parseBackgroundJob(name: string, payload: unknown): BackgroundJob {
|
export function parseBackgroundJob(name: string, payload: unknown): BackgroundJob {
|
||||||
@ -160,3 +161,20 @@ export function profileConsolidateJob(userId: string): BackgroundJob {
|
|||||||
replaceKey: profileConsolidateJobKey(userId),
|
replaceKey: profileConsolidateJobKey(userId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Media jobs share one queue so a single GPU never runs two generations at once. */
|
||||||
|
export const MEDIA_JOB_QUEUE = "media";
|
||||||
|
|
||||||
|
export function mediaRunJobKey(jobId: string): string {
|
||||||
|
return `media.run:${jobId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaRunJob(jobId: string, availableAt?: Date): BackgroundJob {
|
||||||
|
return {
|
||||||
|
name: "media.run",
|
||||||
|
payload: { jobId },
|
||||||
|
replaceKey: mediaRunJobKey(jobId),
|
||||||
|
queue: MEDIA_JOB_QUEUE,
|
||||||
|
...(availableAt ? { availableAt } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -195,13 +195,19 @@ export interface StartMediaJobInput {
|
|||||||
operation: MediaOperation;
|
operation: MediaOperation;
|
||||||
/** Validated by the operation's input schema before the job is created. */
|
/** Validated by the operation's input schema before the job is created. */
|
||||||
input: unknown;
|
input: unknown;
|
||||||
title: string;
|
/** Card label; the operation derives one from the input when omitted. */
|
||||||
|
title?: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
/** Author of the media card; a job started from a card keeps that card's bot. */
|
||||||
botId: string | null;
|
botId: string | null;
|
||||||
groupId: string | null;
|
groupId: string | null;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
runId: string | null;
|
runId: string | null;
|
||||||
|
/** Wake the starting bot with the job's summary when the job ends. */
|
||||||
|
continueAfter?: boolean;
|
||||||
|
/** A replay with the same key returns the job created first (tool call idempotency). */
|
||||||
|
operationKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaJobSnapshot {
|
export interface MediaJobSnapshot {
|
||||||
|
|||||||
@ -524,6 +524,8 @@ export interface BackgroundJobPayloads {
|
|||||||
"cloud_agent.poll": { agentId: string };
|
"cloud_agent.poll": { agentId: string };
|
||||||
/** Learn durable user facts from conversations since the profile's last consolidation. */
|
/** Learn durable user facts from conversations since the profile's last consolidation. */
|
||||||
"profile.consolidate": { userId: string };
|
"profile.consolidate": { userId: string };
|
||||||
|
/** Run one media job; scope, input and lease live on the job row. */
|
||||||
|
"media.run": { jobId: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackgroundJobName = keyof BackgroundJobPayloads;
|
export type BackgroundJobName = keyof BackgroundJobPayloads;
|
||||||
@ -534,6 +536,8 @@ export type BackgroundJob = {
|
|||||||
payload: BackgroundJobPayloads[Name];
|
payload: BackgroundJobPayloads[Name];
|
||||||
availableAt?: Date;
|
availableAt?: Date;
|
||||||
replaceKey?: string;
|
replaceKey?: string;
|
||||||
|
/** Jobs that share a queue run one at a time, in order, across every worker process. */
|
||||||
|
queue?: string;
|
||||||
};
|
};
|
||||||
}[BackgroundJobName];
|
}[BackgroundJobName];
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { scheduleComputerSleep, sleepComputerIfIdle } from "./computer-idle.js";
|
|||||||
import { performComputerUpdate } from "./computer-update.js";
|
import { performComputerUpdate } from "./computer-update.js";
|
||||||
import type { createRunExecutor } from "./executor.js";
|
import type { createRunExecutor } from "./executor.js";
|
||||||
import { compactHistory } from "./history-compaction.js";
|
import { compactHistory } from "./history-compaction.js";
|
||||||
|
import type { MediaRuntimeHost } from "./media-runtime.js";
|
||||||
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
|
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
|
||||||
import { deliverMessagingOutbound, mirrorMessagingOutbound } from "./messaging-delivery.js";
|
import { deliverMessagingOutbound, mirrorMessagingOutbound } from "./messaging-delivery.js";
|
||||||
import { consolidateProfile } from "./profile-consolidation.js";
|
import { consolidateProfile } from "./profile-consolidation.js";
|
||||||
@ -36,6 +37,7 @@ export function createBackgroundJobHandlers(deps: {
|
|||||||
deploymentModelKey?: string;
|
deploymentModelKey?: string;
|
||||||
messaging?: MessagingSurface;
|
messaging?: MessagingSurface;
|
||||||
cloudAgent?: CloudAgentConnection | null;
|
cloudAgent?: CloudAgentConnection | null;
|
||||||
|
media?: MediaRuntimeHost;
|
||||||
}): BackgroundJobHandlers {
|
}): BackgroundJobHandlers {
|
||||||
const deliverMessaging = async (runId?: string) => {
|
const deliverMessaging = async (runId?: string) => {
|
||||||
if (!deps.messaging) return;
|
if (!deps.messaging) return;
|
||||||
@ -112,6 +114,9 @@ export function createBackgroundJobHandlers(deps: {
|
|||||||
payload.threadId,
|
payload.threadId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
"media.run": async (payload) => {
|
||||||
|
await deps.media?.runJob(payload.jobId);
|
||||||
|
},
|
||||||
"profile.consolidate": async (payload) => {
|
"profile.consolidate": async (payload) => {
|
||||||
await consolidateProfile(
|
await consolidateProfile(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type {
|
|||||||
ConnectorProvider,
|
ConnectorProvider,
|
||||||
JobPublisher,
|
JobPublisher,
|
||||||
ManagedConnectorProvider,
|
ManagedConnectorProvider,
|
||||||
|
MediaRuntime,
|
||||||
MemoryStore,
|
MemoryStore,
|
||||||
NotificationProvider,
|
NotificationProvider,
|
||||||
SandboxProvider,
|
SandboxProvider,
|
||||||
@ -45,6 +46,8 @@ export interface ExecutorDeps {
|
|||||||
secretHttp?: RemoteTransportDependencies;
|
secretHttp?: RemoteTransportDependencies;
|
||||||
/** Remote cloud coding agents. Null/omit means tools stay uninjected. */
|
/** Remote cloud coding agents. Null/omit means tools stay uninjected. */
|
||||||
cloudAgent?: CloudAgentConnection | null;
|
cloudAgent?: CloudAgentConnection | null;
|
||||||
|
/** Media jobs (generation, edits, transcription). Omit means media tools stay hidden. */
|
||||||
|
media?: MediaRuntime;
|
||||||
/** Aborted when createApp stop() begins so in-flight continueRun boot waits exit promptly. */
|
/** Aborted when createApp stop() begins so in-flight continueRun boot waits exit promptly. */
|
||||||
shutdownSignal?: AbortSignal;
|
shutdownSignal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,10 @@ export * from "./mcp-emulator.js";
|
|||||||
export * from "./mcp-oauth.js";
|
export * from "./mcp-oauth.js";
|
||||||
export * from "./mcp-server-tool.js";
|
export * from "./mcp-server-tool.js";
|
||||||
export * from "./mcp-transport.js";
|
export * from "./mcp-transport.js";
|
||||||
|
export * from "./media-operations.js";
|
||||||
|
export * from "./media-runtime.js";
|
||||||
|
export * from "./media-settings.js";
|
||||||
|
export * from "./media-tools.js";
|
||||||
export * from "./memory-provider-factory.js";
|
export * from "./memory-provider-factory.js";
|
||||||
export * from "./messaging-context.js";
|
export * from "./messaging-context.js";
|
||||||
export * from "./messaging-delivery.js";
|
export * from "./messaging-delivery.js";
|
||||||
|
|||||||
@ -109,6 +109,7 @@ export function createJobReconciler(
|
|||||||
/** Enqueues daily profile consolidation; see createProfileConsolidationScheduler. */
|
/** Enqueues daily profile consolidation; see createProfileConsolidationScheduler. */
|
||||||
reconcileProfileConsolidation?: () => Promise<void>;
|
reconcileProfileConsolidation?: () => Promise<void>;
|
||||||
reconcileDelegatedTasks?: () => Promise<void>;
|
reconcileDelegatedTasks?: () => Promise<void>;
|
||||||
|
reconcileMediaJobs?: () => Promise<void>;
|
||||||
},
|
},
|
||||||
options: { intervalMs?: number; batchSize?: number } = {},
|
options: { intervalMs?: number; batchSize?: number } = {},
|
||||||
) {
|
) {
|
||||||
@ -132,6 +133,7 @@ export function createJobReconciler(
|
|||||||
deps.reconcileComputerUpdates,
|
deps.reconcileComputerUpdates,
|
||||||
deps.reconcileProfileConsolidation,
|
deps.reconcileProfileConsolidation,
|
||||||
deps.reconcileDelegatedTasks,
|
deps.reconcileDelegatedTasks,
|
||||||
|
deps.reconcileMediaJobs,
|
||||||
].map(async (reconcile) => reconcile?.()),
|
].map(async (reconcile) => reconcile?.()),
|
||||||
);
|
);
|
||||||
for (const result of auxiliary) {
|
for (const result of auxiliary) {
|
||||||
|
|||||||
15
packages/adapters/src/media-operations.ts
Normal file
15
packages/adapters/src/media-operations.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import type { MediaSettings } from "@rakazo/contracts";
|
||||||
|
import type { MediaOperationRegistration } from "./media-runtime.js";
|
||||||
|
|
||||||
|
export interface MediaOperationDeps {
|
||||||
|
/** Current deployment-wide media backends; read per call so edits apply without a restart. */
|
||||||
|
loadSettings(): Promise<MediaSettings>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every media operation this deployment can run. The API and the worker build their runtime
|
||||||
|
* from this one list, so both processes run the same operations.
|
||||||
|
*/
|
||||||
|
export function createMediaOperations(_deps: MediaOperationDeps): MediaOperationRegistration[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
1041
packages/adapters/src/media-runtime.ts
Normal file
1041
packages/adapters/src/media-runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
132
packages/adapters/src/media-settings.ts
Normal file
132
packages/adapters/src/media-settings.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import {
|
||||||
|
type MediaSettings,
|
||||||
|
MediaSettingsSchema,
|
||||||
|
type MediaSettingsUpdate,
|
||||||
|
MediaSettingsUpdateSchema,
|
||||||
|
type MediaSettingsView,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import {
|
||||||
|
assertAllowedOpenAiCompatibleRequestUrl,
|
||||||
|
assertAllowedOpenAiCompatibleUrl,
|
||||||
|
assertHttpsForKeyedOpenAiCompatibleUrl,
|
||||||
|
} from "./openai-compatible-url.js";
|
||||||
|
import type { EncryptedSecretStore } from "./secrets.js";
|
||||||
|
|
||||||
|
/** The IntegrationProviderConfig row that stores the media backends, encrypted as one record. */
|
||||||
|
export const MEDIA_SETTINGS_ID = "media";
|
||||||
|
const RECORD_ID = `integration-provider:${MEDIA_SETTINGS_ID}`;
|
||||||
|
|
||||||
|
export interface MediaSettingsDeps {
|
||||||
|
prisma: Pick<PrismaClient, "integrationProviderConfig">;
|
||||||
|
secrets: Pick<EncryptedSecretStore, "load" | "put">;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOT_CONFIGURED: MediaSettings = { comfyui: null, transcription: null };
|
||||||
|
|
||||||
|
// Decrypting derives a scrypt key; tool selection reads the settings on every run.
|
||||||
|
let cached: { ciphertext: string; settings: MediaSettings } | undefined;
|
||||||
|
|
||||||
|
/** Deployment-wide media backends, read fresh from the database so every process sees edits. */
|
||||||
|
export async function loadMediaSettings(deps: MediaSettingsDeps): Promise<MediaSettings> {
|
||||||
|
const row = await deps.prisma.integrationProviderConfig.findUnique({
|
||||||
|
where: { id: MEDIA_SETTINGS_ID },
|
||||||
|
});
|
||||||
|
if (!row) return NOT_CONFIGURED;
|
||||||
|
if (cached?.ciphertext === row.ciphertext) return cached.settings;
|
||||||
|
const settings = MediaSettingsSchema.parse(
|
||||||
|
JSON.parse(deps.secrets.load(row.ciphertext, RECORD_ID)),
|
||||||
|
);
|
||||||
|
cached = { ciphertext: row.ciphertext, settings };
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaSettingsView(settings: MediaSettings): MediaSettingsView {
|
||||||
|
return {
|
||||||
|
comfyui: settings.comfyui ? withoutKey(settings.comfyui) : null,
|
||||||
|
transcription: settings.transcription ? withoutKey(settings.transcription) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutKey<T extends { apiKey?: string }>({ apiKey, ...rest }: T) {
|
||||||
|
return { ...rest, hasApiKey: Boolean(apiKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ComfyUI root such as `http://127.0.0.1:8188`, without credentials, query or fragment. */
|
||||||
|
export function normalizeComfyUiBaseUrl(raw: string): string {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw.trim());
|
||||||
|
} catch {
|
||||||
|
throw new Error("Server URL must be an absolute HTTP(S) URL");
|
||||||
|
}
|
||||||
|
if (url.username || url.password) throw new Error("Server URL must not contain credentials");
|
||||||
|
const normalized = `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
||||||
|
// Same address policy as OpenAI-compatible servers: private hosts pass, metadata hosts never.
|
||||||
|
return assertAllowedOpenAiCompatibleRequestUrl(normalized).href.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An OpenAI-compatible API root ending in a version segment, such as `…/v1`. */
|
||||||
|
export function normalizeTranscriptionBaseUrl(raw: string): string {
|
||||||
|
return assertAllowedOpenAiCompatibleUrl(raw).href.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function keptKey(
|
||||||
|
next: string | undefined,
|
||||||
|
previous: { baseUrl: string; apiKey?: string } | null,
|
||||||
|
baseUrl: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (next !== undefined) return next || undefined;
|
||||||
|
// A saved key never follows the settings to another server.
|
||||||
|
if (!previous?.apiKey) return undefined;
|
||||||
|
return new URL(previous.baseUrl).origin === new URL(baseUrl).origin ? previous.apiKey : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and stores the media backends (deployment owner only; the caller authorizes).
|
||||||
|
* Throws an Error with a user-facing message when a URL is not allowed.
|
||||||
|
*/
|
||||||
|
export async function saveMediaSettings(
|
||||||
|
deps: MediaSettingsDeps,
|
||||||
|
update: MediaSettingsUpdate,
|
||||||
|
): Promise<MediaSettingsView> {
|
||||||
|
const parsed = MediaSettingsUpdateSchema.parse(update);
|
||||||
|
const previous = await loadMediaSettings(deps);
|
||||||
|
let comfyui: MediaSettings["comfyui"] = null;
|
||||||
|
if (parsed.comfyui) {
|
||||||
|
const baseUrl = normalizeComfyUiBaseUrl(parsed.comfyui.baseUrl);
|
||||||
|
const apiKey = keptKey(parsed.comfyui.apiKey, previous.comfyui, baseUrl);
|
||||||
|
assertHttpsForKeyedOpenAiCompatibleUrl(new URL(baseUrl), apiKey);
|
||||||
|
comfyui = { ...parsed.comfyui, baseUrl, apiKey };
|
||||||
|
}
|
||||||
|
let transcription: MediaSettings["transcription"] = null;
|
||||||
|
if (parsed.transcription) {
|
||||||
|
const baseUrl = normalizeTranscriptionBaseUrl(parsed.transcription.baseUrl);
|
||||||
|
const apiKey = keptKey(parsed.transcription.apiKey, previous.transcription, baseUrl);
|
||||||
|
assertHttpsForKeyedOpenAiCompatibleUrl(new URL(baseUrl), apiKey);
|
||||||
|
transcription = { ...parsed.transcription, baseUrl, apiKey };
|
||||||
|
}
|
||||||
|
const settings = MediaSettingsSchema.parse({ comfyui, transcription });
|
||||||
|
if (!settings.comfyui && !settings.transcription) {
|
||||||
|
await deps.prisma.integrationProviderConfig.deleteMany({ where: { id: MEDIA_SETTINGS_ID } });
|
||||||
|
return mediaSettingsView(settings);
|
||||||
|
}
|
||||||
|
const stored = await deps.secrets.put(
|
||||||
|
JSON.stringify(settings),
|
||||||
|
{
|
||||||
|
operationId: "media-settings",
|
||||||
|
traceId: "media-settings",
|
||||||
|
spaceId: "",
|
||||||
|
userId: "",
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
},
|
||||||
|
RECORD_ID,
|
||||||
|
);
|
||||||
|
await deps.prisma.integrationProviderConfig.upsert({
|
||||||
|
where: { id: MEDIA_SETTINGS_ID },
|
||||||
|
create: { id: MEDIA_SETTINGS_ID, ciphertext: stored.ciphertext },
|
||||||
|
update: { ciphertext: stored.ciphertext },
|
||||||
|
});
|
||||||
|
cached = { ciphertext: stored.ciphertext, settings };
|
||||||
|
return mediaSettingsView(settings);
|
||||||
|
}
|
||||||
78
packages/adapters/src/media-tools.ts
Normal file
78
packages/adapters/src/media-tools.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import type { ConnectorTool, MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
|
||||||
|
import { type MediaOperation, MediaOperationSchema } from "@rakazo/contracts";
|
||||||
|
import { MediaJobRejectedError } from "./media-runtime.js";
|
||||||
|
|
||||||
|
/** Media tools are named exactly like their operations and dispatched by one module. */
|
||||||
|
const MEDIA_TOOL_NAMES: ReadonlySet<string> = new Set(MediaOperationSchema.options);
|
||||||
|
|
||||||
|
export function isMediaToolName(name: string): name is MediaOperation {
|
||||||
|
return MEDIA_TOOL_NAMES.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared `continue_after` property every media tool definition accepts. */
|
||||||
|
export const MEDIA_CONTINUE_AFTER_PROPERTY = {
|
||||||
|
continue_after: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"Wake me with the result when the job ends so I can continue a multi-step task. Default false: the media card shows the result to the user.",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** A media tool's JSON schema: its own properties plus `continue_after`. */
|
||||||
|
export function mediaToolInputSchema(schema: {
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
}): ConnectorTool["inputSchema"] {
|
||||||
|
return {
|
||||||
|
type: "object",
|
||||||
|
properties: { ...schema.properties, ...MEDIA_CONTINUE_AFTER_PROPERTY },
|
||||||
|
...(schema.required?.length ? { required: schema.required } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops media tools whose operation the space cannot run right now. */
|
||||||
|
export function selectMediaTools<T extends { name: string }>(
|
||||||
|
tools: T[],
|
||||||
|
capabilities: MediaCapabilities | undefined,
|
||||||
|
): T[] {
|
||||||
|
return tools.filter((tool) => !isMediaToolName(tool.name) || capabilities?.[tool.name] === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the job behind a media tool call; the turn never waits for the job itself. */
|
||||||
|
export async function executeMediaTool(
|
||||||
|
media: MediaRuntime | undefined,
|
||||||
|
scope: {
|
||||||
|
spaceId: string;
|
||||||
|
userId: string;
|
||||||
|
botId: string;
|
||||||
|
groupId: string | null;
|
||||||
|
threadId: string;
|
||||||
|
runId: string;
|
||||||
|
/** The tool call's effect key, so a replayed call returns the job it started. */
|
||||||
|
operationKey: string;
|
||||||
|
},
|
||||||
|
operation: MediaOperation,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (!media) return { error: `${operation} is not available.` };
|
||||||
|
const { continue_after: continueAfter, ...input } = args;
|
||||||
|
try {
|
||||||
|
const started = await media.start({
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
...scope,
|
||||||
|
continueAfter: continueAfter === true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
jobId: started.jobId,
|
||||||
|
status: "queued",
|
||||||
|
note:
|
||||||
|
continueAfter === true
|
||||||
|
? "The job runs in the background. You are woken with its result when it ends; do not poll or wait."
|
||||||
|
: "The job runs in the background and its card shows progress and the result to the user. Do not poll or wait.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof MediaJobRejectedError) return { error: error.message };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ function handlers(): BackgroundJobHandlers {
|
|||||||
"messaging.deliver": vi.fn(async () => undefined),
|
"messaging.deliver": vi.fn(async () => undefined),
|
||||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||||
"profile.consolidate": vi.fn(async () => undefined),
|
"profile.consolidate": vi.fn(async () => undefined),
|
||||||
|
"media.run": vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ function handlers(overrides: Partial<BackgroundJobHandlers> = {}): BackgroundJob
|
|||||||
"messaging.deliver": vi.fn(async () => undefined),
|
"messaging.deliver": vi.fn(async () => undefined),
|
||||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||||
"profile.consolidate": vi.fn(async () => undefined),
|
"profile.consolidate": vi.fn(async () => undefined),
|
||||||
|
"media.run": vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -141,6 +142,39 @@ describePostgres("Graphile background jobs (PostgreSQL contract)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs jobs that share a queue one at a time across workers", async () => {
|
||||||
|
const publisher = new GraphileJobPublisher(testPool());
|
||||||
|
const first = new GraphileJobWorkerHost(testPool(), { concurrency: 2, pollInterval: 25 });
|
||||||
|
const second = new GraphileJobWorkerHost(testPool(), { concurrency: 2, pollInterval: 25 });
|
||||||
|
const queue = `contract-queue-${Date.now()}`;
|
||||||
|
let active = 0;
|
||||||
|
let maxActive = 0;
|
||||||
|
const done: string[] = [];
|
||||||
|
const target = handlers({
|
||||||
|
"media.run": async ({ jobId }) => {
|
||||||
|
active += 1;
|
||||||
|
maxActive = Math.max(maxActive, active);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
active -= 1;
|
||||||
|
done.push(jobId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await first.start(target);
|
||||||
|
await second.start(target);
|
||||||
|
for (const jobId of ["q1", "q2", "q3"]) {
|
||||||
|
await publisher.enqueue({ name: "media.run", payload: { jobId }, queue });
|
||||||
|
}
|
||||||
|
await waitFor(() => expect(done).toHaveLength(3));
|
||||||
|
expect(maxActive).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await first.stop();
|
||||||
|
await second.stop();
|
||||||
|
await publisher.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("persists a handler failure and retries the job successfully", async () => {
|
it("persists a handler failure and retries the job successfully", async () => {
|
||||||
const publisher = new GraphileJobPublisher(testPool());
|
const publisher = new GraphileJobPublisher(testPool());
|
||||||
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
||||||
|
|||||||
@ -14,6 +14,7 @@ function handlers(): BackgroundJobHandlers {
|
|||||||
"messaging.deliver": vi.fn(async () => undefined),
|
"messaging.deliver": vi.fn(async () => undefined),
|
||||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||||
"profile.consolidate": vi.fn(async () => undefined),
|
"profile.consolidate": vi.fn(async () => undefined),
|
||||||
|
"media.run": vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,6 +199,59 @@ describe("InMemoryJobQueue", () => {
|
|||||||
await expect(enqueue).rejects.toThrow("Background job publisher is stopped");
|
await expect(enqueue).rejects.toThrow("Background job publisher is stopped");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs jobs of one named queue one at a time, in order", async () => {
|
||||||
|
const queue = new InMemoryJobQueue();
|
||||||
|
const order: string[] = [];
|
||||||
|
let active = 0;
|
||||||
|
let maxActive = 0;
|
||||||
|
const target = handlers();
|
||||||
|
target["media.run"] = async ({ jobId }) => {
|
||||||
|
active += 1;
|
||||||
|
maxActive = Math.max(maxActive, active);
|
||||||
|
order.push(`start:${jobId}`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
order.push(`end:${jobId}`);
|
||||||
|
active -= 1;
|
||||||
|
};
|
||||||
|
target["computer.sleep"] = async () => {
|
||||||
|
order.push("unqueued");
|
||||||
|
};
|
||||||
|
await queue.start(target);
|
||||||
|
|
||||||
|
for (const jobId of ["a", "b", "c"]) {
|
||||||
|
await queue.enqueue({ name: "media.run", payload: { jobId }, queue: "media" });
|
||||||
|
}
|
||||||
|
await queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } });
|
||||||
|
await queue.close();
|
||||||
|
|
||||||
|
expect(maxActive).toBe(1);
|
||||||
|
expect(order.filter((entry) => entry !== "unqueued")).toEqual([
|
||||||
|
"start:a",
|
||||||
|
"end:a",
|
||||||
|
"start:b",
|
||||||
|
"end:b",
|
||||||
|
"start:c",
|
||||||
|
"end:c",
|
||||||
|
]);
|
||||||
|
// Jobs outside the queue do not wait for it.
|
||||||
|
expect(order.indexOf("unqueued")).toBeLessThan(order.indexOf("end:a"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a named queue moving after a job fails", async () => {
|
||||||
|
const queue = new InMemoryJobQueue();
|
||||||
|
const ran: string[] = [];
|
||||||
|
const target = handlers();
|
||||||
|
target["media.run"] = async ({ jobId }) => {
|
||||||
|
ran.push(jobId);
|
||||||
|
if (jobId === "a") throw new Error("boom");
|
||||||
|
};
|
||||||
|
await queue.start(target);
|
||||||
|
await queue.enqueue({ name: "media.run", payload: { jobId: "a" }, queue: "media" });
|
||||||
|
await queue.enqueue({ name: "media.run", payload: { jobId: "b" }, queue: "media" });
|
||||||
|
await queue.close();
|
||||||
|
expect(ran).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("drains an accepted immediate timer during close", async () => {
|
it("drains an accepted immediate timer during close", async () => {
|
||||||
const queue = new InMemoryJobQueue();
|
const queue = new InMemoryJobQueue();
|
||||||
const target = handlers();
|
const target = handlers();
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export class GraphileJobPublisher implements JobPublisher {
|
|||||||
await utils.addJob(job.name, wrapJobPayload(job.payload), {
|
await utils.addJob(job.name, wrapJobPayload(job.payload), {
|
||||||
runAt: job.availableAt,
|
runAt: job.availableAt,
|
||||||
jobKey: job.replaceKey,
|
jobKey: job.replaceKey,
|
||||||
|
queueName: job.queue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,6 +181,7 @@ interface QueuedJob {
|
|||||||
payload: unknown;
|
payload: unknown;
|
||||||
availableAt?: Date;
|
availableAt?: Date;
|
||||||
replaceKey?: string;
|
replaceKey?: string;
|
||||||
|
queue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toQueuedJob(job: BackgroundJob): QueuedJob {
|
function toQueuedJob(job: BackgroundJob): QueuedJob {
|
||||||
@ -188,6 +190,7 @@ function toQueuedJob(job: BackgroundJob): QueuedJob {
|
|||||||
payload: wrapJobPayload(job.payload),
|
payload: wrapJobPayload(job.payload),
|
||||||
availableAt: job.availableAt,
|
availableAt: job.availableAt,
|
||||||
replaceKey: job.replaceKey,
|
replaceKey: job.replaceKey,
|
||||||
|
queue: job.queue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,6 +200,8 @@ export class InMemoryJobQueue implements JobPublisher, JobWorkerHost {
|
|||||||
private readonly scheduled = new Map<ReturnType<typeof setTimeout>, QueuedJob>();
|
private readonly scheduled = new Map<ReturnType<typeof setTimeout>, QueuedJob>();
|
||||||
private readonly keyed = new Map<string, ReturnType<typeof setTimeout>>();
|
private readonly keyed = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
private readonly active = new Set<Promise<void>>();
|
private readonly active = new Set<Promise<void>>();
|
||||||
|
/** Last job of each named queue; the next one starts after it settles, like Graphile queues. */
|
||||||
|
private readonly queueTails = new Map<string, Promise<void>>();
|
||||||
private readonly closingJobs: QueuedJob[] = [];
|
private readonly closingJobs: QueuedJob[] = [];
|
||||||
private draining: Promise<void> | undefined;
|
private draining: Promise<void> | undefined;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
@ -260,12 +265,22 @@ export class InMemoryJobQueue implements JobPublisher, JobWorkerHost {
|
|||||||
|
|
||||||
private dispatch(handlers: BackgroundJobHandlers, job: QueuedJob): Promise<void> {
|
private dispatch(handlers: BackgroundJobHandlers, job: QueuedJob): Promise<void> {
|
||||||
const unpacked = unwrapJobPayload(job.payload);
|
const unpacked = unwrapJobPayload(job.payload);
|
||||||
const active = runCorrelatedJob({
|
const run = () =>
|
||||||
name: job.name,
|
runCorrelatedJob({
|
||||||
payload: unpacked.payload,
|
name: job.name,
|
||||||
correlation: unpacked.correlation,
|
payload: unpacked.payload,
|
||||||
run: () => dispatchBackgroundJob(handlers, job.name, unpacked.payload),
|
correlation: unpacked.correlation,
|
||||||
}).catch(() => undefined);
|
run: () => dispatchBackgroundJob(handlers, job.name, unpacked.payload),
|
||||||
|
});
|
||||||
|
const queue = job.queue;
|
||||||
|
const tail = queue ? this.queueTails.get(queue) : undefined;
|
||||||
|
const active = (tail ? tail.then(run) : run()).catch(() => undefined);
|
||||||
|
if (queue) {
|
||||||
|
this.queueTails.set(queue, active);
|
||||||
|
void active.finally(() => {
|
||||||
|
if (this.queueTails.get(queue) === active) this.queueTails.delete(queue);
|
||||||
|
});
|
||||||
|
}
|
||||||
this.active.add(active);
|
this.active.add(active);
|
||||||
void active.finally(() => this.active.delete(active));
|
void active.finally(() => this.active.delete(active));
|
||||||
return active;
|
return active;
|
||||||
|
|||||||
@ -922,6 +922,7 @@ export const RunSchema = z.object({
|
|||||||
"messaging",
|
"messaging",
|
||||||
"cloud_agent",
|
"cloud_agent",
|
||||||
"delegation",
|
"delegation",
|
||||||
|
"media",
|
||||||
]),
|
]),
|
||||||
routineId: Id.nullable(),
|
routineId: Id.nullable(),
|
||||||
modelProvider: z.string().nullable(),
|
modelProvider: z.string().nullable(),
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export * from "./integration-settings.js";
|
|||||||
export * from "./local-settings.js";
|
export * from "./local-settings.js";
|
||||||
export * from "./mcp.js";
|
export * from "./mcp.js";
|
||||||
export * from "./media.js";
|
export * from "./media.js";
|
||||||
|
export * from "./media-settings.js";
|
||||||
export * from "./model-tiers.js";
|
export * from "./model-tiers.js";
|
||||||
export * from "./openai-compatible-ui.js";
|
export * from "./openai-compatible-ui.js";
|
||||||
export * from "./project-goals.js";
|
export * from "./project-goals.js";
|
||||||
|
|||||||
78
packages/contracts/src/media-settings.ts
Normal file
78
packages/contracts/src/media-settings.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
/** Cap for a custom ComfyUI workflow (API-format JSON). */
|
||||||
|
export const MEDIA_WORKFLOW_MAX_LENGTH = 512 * 1024;
|
||||||
|
|
||||||
|
/** Empty form fields mean "not set". */
|
||||||
|
function optionalText(max: number) {
|
||||||
|
return z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max)
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonObject(value: string | undefined): boolean {
|
||||||
|
if (value === undefined) return true;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = optionalText(MEDIA_WORKFLOW_MAX_LENGTH).refine(isJsonObject, {
|
||||||
|
message: "Workflow must be a JSON object",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Image and video generation on the deployment owner's ComfyUI server. */
|
||||||
|
export const ComfyUiSettingsSchema = z.object({
|
||||||
|
baseUrl: z.string().trim().min(1).max(2048),
|
||||||
|
apiKey: optionalText(4096),
|
||||||
|
/** Checkpoint name; the first installed checkpoint when unset. */
|
||||||
|
imageModel: optionalText(512),
|
||||||
|
/** Custom API-format workflows that replace the built-in defaults. */
|
||||||
|
imageWorkflow: workflow,
|
||||||
|
videoWorkflow: workflow,
|
||||||
|
});
|
||||||
|
export type ComfyUiSettings = z.infer<typeof ComfyUiSettingsSchema>;
|
||||||
|
|
||||||
|
/** Speech to text on an OpenAI-compatible server (`POST /v1/audio/transcriptions`). */
|
||||||
|
export const TranscriptionSettingsSchema = z.object({
|
||||||
|
baseUrl: z.string().trim().min(1).max(2048),
|
||||||
|
model: optionalText(512),
|
||||||
|
apiKey: optionalText(4096),
|
||||||
|
});
|
||||||
|
export type TranscriptionSettings = z.infer<typeof TranscriptionSettingsSchema>;
|
||||||
|
|
||||||
|
/** Deployment-wide media backends; null means the backend is not configured. */
|
||||||
|
export const MediaSettingsSchema = z.object({
|
||||||
|
comfyui: ComfyUiSettingsSchema.nullable(),
|
||||||
|
transcription: TranscriptionSettingsSchema.nullable(),
|
||||||
|
});
|
||||||
|
export type MediaSettings = z.infer<typeof MediaSettingsSchema>;
|
||||||
|
|
||||||
|
/** What the settings screen reads: saved keys are never returned, only whether one exists. */
|
||||||
|
export const MediaSettingsViewSchema = z.object({
|
||||||
|
comfyui: ComfyUiSettingsSchema.omit({ apiKey: true })
|
||||||
|
.extend({ hasApiKey: z.boolean() })
|
||||||
|
.nullable(),
|
||||||
|
transcription: TranscriptionSettingsSchema.omit({ apiKey: true })
|
||||||
|
.extend({ hasApiKey: z.boolean() })
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
export type MediaSettingsView = z.infer<typeof MediaSettingsViewSchema>;
|
||||||
|
|
||||||
|
const keyUpdate = z.string().trim().max(4096).optional();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saving replaces both backends. An omitted `apiKey` keeps the saved key while the server's
|
||||||
|
* origin stays the same; an empty one removes it.
|
||||||
|
*/
|
||||||
|
export const MediaSettingsUpdateSchema = z.object({
|
||||||
|
comfyui: ComfyUiSettingsSchema.extend({ apiKey: keyUpdate }).nullable(),
|
||||||
|
transcription: TranscriptionSettingsSchema.extend({ apiKey: keyUpdate }).nullable(),
|
||||||
|
});
|
||||||
|
export type MediaSettingsUpdate = z.input<typeof MediaSettingsUpdateSchema>;
|
||||||
@ -22,6 +22,7 @@ export const RunActivityRowSchema = z.object({
|
|||||||
"messaging",
|
"messaging",
|
||||||
"cloud_agent",
|
"cloud_agent",
|
||||||
"delegation",
|
"delegation",
|
||||||
|
"media",
|
||||||
]),
|
]),
|
||||||
notificationsEnabled: z.boolean(),
|
notificationsEnabled: z.boolean(),
|
||||||
promptSnippet: z.string(),
|
promptSnippet: z.string(),
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
-- Media jobs run on whichever process claims them first: a fenced lease keeps two processes from
|
||||||
|
-- running one job, deferrals bound how long a job waits for the GPU, an operation key makes a
|
||||||
|
-- replayed tool call return the job it started, and continueAfter wakes the bot when it ends.
|
||||||
|
ALTER TABLE "media_jobs" ADD COLUMN "deferrals" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "media_jobs" ADD COLUMN "continueAfter" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "media_jobs" ADD COLUMN "operationKey" TEXT;
|
||||||
|
ALTER TABLE "media_jobs" ADD COLUMN "leaseToken" TEXT;
|
||||||
|
ALTER TABLE "media_jobs" ADD COLUMN "leaseExpiresAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "media_jobs" ADD COLUMN "nextAttemptAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "media_jobs_operationKey_key" ON "media_jobs"("operationKey");
|
||||||
|
CREATE INDEX "media_jobs_status_updatedAt_idx" ON "media_jobs"("status", "updatedAt");
|
||||||
@ -1102,14 +1102,27 @@ model MediaJob {
|
|||||||
progress Float?
|
progress Float?
|
||||||
error String?
|
error String?
|
||||||
outputArtifactIds String[] @default([])
|
outputArtifactIds String[] @default([])
|
||||||
|
/// Times a runner claimed the job; with deferrals this bounds crash recovery.
|
||||||
attempts Int @default(0)
|
attempts Int @default(0)
|
||||||
|
/// Times the job waited for the GPU or its backend instead of running.
|
||||||
|
deferrals Int @default(0)
|
||||||
cancelRequested Boolean @default(false)
|
cancelRequested Boolean @default(false)
|
||||||
|
/// Wake the starting bot with the job's summary when it ends.
|
||||||
|
continueAfter Boolean @default(false)
|
||||||
|
/// Tool-call effect key; a replay returns the job created first.
|
||||||
|
operationKey String? @unique
|
||||||
|
/// Fenced runner lease so two processes never run one job.
|
||||||
|
leaseToken String?
|
||||||
|
leaseExpiresAt DateTime?
|
||||||
|
/// When a waiting job may run again.
|
||||||
|
nextAttemptAt DateTime?
|
||||||
startedAt DateTime?
|
startedAt DateTime?
|
||||||
finishedAt DateTime?
|
finishedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([spaceId, status])
|
@@index([spaceId, status])
|
||||||
|
@@index([status, updatedAt])
|
||||||
@@index([threadId])
|
@@index([threadId])
|
||||||
@@map("media_jobs")
|
@@map("media_jobs")
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user