feat: add durable CMS-free image video subsystem and UI components
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ImageVideoDraftSchema = z.object({ attachmentId: z.string().regex(/^att_[\w-]+$/), title: z.string().trim().min(1).max(120), prompt: z.string().trim().min(1).max(4000), durationSeconds: z.number().positive().max(60) }).strict();
|
||||
export type ImageVideoDraft = z.infer<typeof ImageVideoDraftSchema>;
|
||||
export const taskIdSchema = z.string().regex(/^i2v_[0-9a-f-]{36}$/);
|
||||
export const validationSchema = z.object({ videoSeconds: z.number().positive(), audioSeconds: z.number().positive(), width: z.number().int().positive(), height: z.number().int().positive(), fps: z.number().positive(), decoded: z.literal(true), sha256: z.string().regex(/^[a-f0-9]{64}$/), visualReview: z.literal("pending"), audioReview: z.literal("pending") }).strict();
|
||||
export type ImageVideoValidation = z.infer<typeof validationSchema>;
|
||||
export const recordSchema = z.object({
|
||||
version: z.literal(1), id: taskIdSchema, ownerSessionId: z.string().regex(/^ses_[\w-]+$/), revision: z.number().int().nonnegative(),
|
||||
draft: ImageVideoDraftSchema, requestHash: z.string().regex(/^[a-f0-9]{64}$/), referenceHash: z.string().regex(/^[a-f0-9]{64}$/), referenceType: z.enum(["image/jpeg", "image/png", "image/webp"]),
|
||||
state: z.enum(["draft", "initializing_upload", "uploading", "finalizing", "importing", "awaiting_submission", "submitting", "queued", "running", "validating", "ready", "failed", "unknown_submission", "unknown_upload"]),
|
||||
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), lastPollAt: z.number().optional(),
|
||||
identity: z.object({ tenantId: z.string().min(1), platform: z.string().min(1) }).strict().optional(),
|
||||
upload: z.object({ uploadId: z.string().min(1), objectKey: z.string().min(1), uploadUrl: z.string().url() }).strict().optional(),
|
||||
assetId: z.string().min(1).optional(), jobId: z.string().min(1).max(200).optional(), artifactUrl: z.string().url().optional(),
|
||||
error: z.string().max(500).optional(), validation: validationSchema.optional(),
|
||||
}).strict();
|
||||
export type ImageVideoRecord = z.infer<typeof recordSchema>;
|
||||
export type ImageVideoPublicTask = { id: string; title: string; prompt: string; durationSeconds: number; state: string; createdAt: string; updatedAt: string; referenceUrl: string; videoUrl?: string; jobId?: string; error?: string; validation?: ImageVideoValidation };
|
||||
export interface ImageVideoRemote {
|
||||
preflight(): Promise<{ tenantId: string; platform: string }>;
|
||||
initialize(record: ImageVideoRecord, bytes: Buffer): Promise<{ uploadId: string; objectKey: string; uploadUrl: string }>;
|
||||
put(record: ImageVideoRecord, bytes: Buffer): Promise<void>;
|
||||
finalize(record: ImageVideoRecord): Promise<void>;
|
||||
importStatus(record: ImageVideoRecord): Promise<{ state: "pending" | "ready" | "failed"; assetId?: string }>;
|
||||
submit(record: ImageVideoRecord): Promise<{ jobId: string }>;
|
||||
status(record: ImageVideoRecord): Promise<{ state: "queued" | "running" | "succeeded" | "failed"; artifactUrl?: string }>;
|
||||
}
|
||||
export const IMAGE_VIDEO_RESOURCES = ["references/selection.md", "references/swads.md", "references/local-processing.md", "scripts/media.py"];
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { z } from "zod";
|
||||
import { Check } from "typebox/value";
|
||||
import type { TSchema } from "typebox";
|
||||
import { HarnessError } from "../errors/harness-error.js";
|
||||
import { SWADS_MCP_URL } from "../tools/swads-mcp.js";
|
||||
import type { ImageVideoRecord, ImageVideoRemote } from "./contracts.js";
|
||||
import { imageVideoTransfer, validateImageVideoUrl } from "./media.js";
|
||||
|
||||
const names = ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"] as const;
|
||||
const nonempty = z.string().min(1).max(2048);
|
||||
export function assertImageVideoArguments(schema: Record<string, unknown>, args: Record<string, unknown>): void {
|
||||
const properties = z.record(z.string(), z.unknown()).parse(schema.properties ?? {});
|
||||
if (Object.keys(args).some(key => !(key in properties)) || !Check(schema as TSchema, args)) throw new HarnessError("I2V_SCHEMA_CHANGED", "SW Ads request schema changed; operation blocked");
|
||||
}
|
||||
export function imageVideoResponse(value: unknown): Record<string, unknown> {
|
||||
try {
|
||||
const envelope = z.object({ isError: z.boolean().optional(), structuredContent: z.unknown().optional(), content: z.array(z.object({ type: z.string(), text: z.string().optional() })).optional() }).parse(value);
|
||||
if (envelope.isError) throw new Error("Remote error");
|
||||
const texts = envelope.content?.filter(part => part.type === "text");
|
||||
const result = envelope.structuredContent ?? (texts?.length === 1 && texts[0]?.text ? JSON.parse(texts[0].text) as unknown : undefined);
|
||||
return z.record(z.string(), z.unknown()).parse(result);
|
||||
} catch { throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "SW Ads response contract is unclear; retain the task and inspect its receipt"); }
|
||||
}
|
||||
|
||||
/** Dedicated capability set. The analytics client's allowlist is never broadened. */
|
||||
export class ImageVideoMcpRemote implements ImageVideoRemote {
|
||||
#connection: Promise<Client> | undefined;
|
||||
#schemas = new Map<string, Record<string, unknown>>();
|
||||
constructor(private readonly options: { token: string }) {}
|
||||
private async connect() {
|
||||
if (!this.options.token.trim()) throw new HarnessError("SWADS_NOT_CONFIGURED", "SWADS_MCP_TOKEN is not configured on the server");
|
||||
this.#connection ??= (async () => {
|
||||
const client = new Client({ name: "agent-studio-image-video", version: "1.0.0" });
|
||||
try { await client.connect(new StreamableHTTPClientTransport(new URL(SWADS_MCP_URL), { requestInit: { redirect: "error", headers: { Authorization: `Bearer ${this.options.token}` } } }) as Transport, { timeout: 20_000 }); return client; }
|
||||
catch { await client.close().catch(() => undefined); this.#connection = undefined; throw new HarnessError("I2V_CONNECTION_FAILED", "SW Ads connection failed"); }
|
||||
})();
|
||||
return this.#connection;
|
||||
}
|
||||
async close() { const client = await this.#connection?.catch(() => undefined); this.#connection = undefined; await client?.close(); }
|
||||
async preflight() {
|
||||
const client = await this.connect(); this.#schemas.clear();
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await client.listTools(cursor ? { cursor } : {}, { timeout: 20_000 });
|
||||
for (const tool of page.tools) if (names.some(name => name === tool.name)) this.#schemas.set(tool.name, tool.inputSchema);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
for (const name of names) if (!this.#schemas.has(name)) throw new HarnessError("I2V_CAPABILITY_MISSING", `Required capability unavailable: ${name}`);
|
||||
const identity = z.object({ access: z.object({ current_account: z.object({ tenant_id: nonempty, platform: nonempty }) }) }).safeParse(await this.call("swads_whoami", {}));
|
||||
if (!identity.success) throw new HarnessError("I2V_IDENTITY_AMBIGUOUS", "Current MCP tenant/platform is not unambiguous; upload blocked");
|
||||
return { tenantId: identity.data.access.current_account.tenant_id, platform: identity.data.access.current_account.platform };
|
||||
}
|
||||
private async call(name: typeof names[number], args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (!names.includes(name)) throw new HarnessError("I2V_TOOL_DENIED", "Tool unavailable");
|
||||
const schema = this.#schemas.get(name);
|
||||
// Status collection after restart discovers the same live capability set.
|
||||
if (!schema) { await this.preflight(); return this.call(name, args); }
|
||||
assertImageVideoArguments(schema, args);
|
||||
try {
|
||||
const result = await (await this.connect()).callTool({ name, arguments: args }, undefined, { timeout: 60_000 });
|
||||
return imageVideoResponse(result);
|
||||
} catch { throw new HarnessError("I2V_REMOTE_FAILED", "SW Ads request failed or its response is unsupported; inspect saved task state"); }
|
||||
}
|
||||
private uploadArgs(r: ImageVideoRecord) {
|
||||
if (!r.identity) throw new HarnessError("I2V_IDENTITY_AMBIGUOUS", "Missing approved upload identity");
|
||||
return { tenant_id: r.identity.tenantId, platforms: [r.identity.platform], platform: r.identity.platform, media_kind: "image", review_status: "review_pending", asset_role: "reference" };
|
||||
}
|
||||
async initialize(r: ImageVideoRecord, bytes: Buffer) {
|
||||
const ext = r.referenceType.split("/")[1];
|
||||
const receipt = z.object({ upload_id: nonempty, object_key: nonempty, upload_url: z.string().url() }).parse(await this.call("creative_upload_from_url", { ...this.uploadArgs(r), file_name: `${r.id}.${ext}`, content_type: r.referenceType, size_bytes: bytes.length, content_sha256: r.referenceHash }));
|
||||
return { uploadId: receipt.upload_id, objectKey: receipt.object_key, uploadUrl: receipt.upload_url };
|
||||
}
|
||||
async put(r: ImageVideoRecord, bytes: Buffer) { if (!r.upload) throw new Error("Missing receipt"); await imageVideoTransfer(r.upload.uploadUrl, { bytes, contentType: r.referenceType }); }
|
||||
async finalize(r: ImageVideoRecord) {
|
||||
if (!r.upload) throw new Error("Missing receipt");
|
||||
const result = await this.call("creative_upload_from_url", { ...this.uploadArgs(r), upload_id: r.upload.uploadId, object_key: r.upload.objectKey });
|
||||
if (result.status !== "finalizing") throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Finalization receipt is unclear; poll the saved upload ID");
|
||||
}
|
||||
async importStatus(r: ImageVideoRecord) {
|
||||
if (!r.upload) throw new Error("Missing receipt");
|
||||
const value = await this.call("creative_import_status", { upload_id: r.upload.uploadId });
|
||||
if (typeof value.creative_factory_asset_id === "string" && value.creative_factory_asset_id) return { state: "ready" as const, assetId: value.creative_factory_asset_id };
|
||||
if (value.status === "failed") return { state: "failed" as const };
|
||||
if (["pending", "finalizing", "processing"].includes(String(value.status))) return { state: "pending" as const };
|
||||
throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Import response has no unambiguous asset receipt");
|
||||
}
|
||||
async submit(r: ImageVideoRecord) {
|
||||
if (!r.assetId) throw new Error("Missing reference asset");
|
||||
const result = z.object({ job_id: nonempty }).parse(await this.call("video_h3_submit", { prompt: r.draft.prompt, title: r.draft.title, reference_asset_ids: [r.assetId], aspect_ratio: "9:16", duration_seconds: r.draft.durationSeconds, resolution: "768p", h3_mode: "ref2va", idempotency_key: `${r.id}/${r.requestHash}`, skill_id: "swads-image-to-video" }));
|
||||
return { jobId: result.job_id };
|
||||
}
|
||||
async status(r: ImageVideoRecord) {
|
||||
if (!r.jobId) throw new Error("Missing job");
|
||||
const value = await this.call("video_job_status", { job_id: r.jobId });
|
||||
if (value.job_id !== undefined && value.job_id !== r.jobId) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Returned job does not match registered job");
|
||||
if (["pending", "queued"].includes(String(value.status))) return { state: "queued" as const };
|
||||
if (["running", "processing"].includes(String(value.status))) return { state: "running" as const };
|
||||
if (["failed", "rejected", "cancelled"].includes(String(value.status))) return { state: "failed" as const };
|
||||
if (!["succeeded", "completed", "success"].includes(String(value.status))) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Unknown generation state");
|
||||
const artifacts = z.array(z.object({ content_type: z.string(), url: z.string().url() })).parse(value.artifacts).filter(artifact => artifact.content_type === "video/mp4");
|
||||
if (artifacts.length !== 1 || !artifacts[0]) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Expected one registered MP4 artifact");
|
||||
await validateImageVideoUrl(artifacts[0].url);
|
||||
return { state: "succeeded" as const, artifactUrl: artifacts[0].url };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { request } from "node:https";
|
||||
import { isIP } from "node:net";
|
||||
import { readFile, writeFile, rename } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { HarnessError } from "../errors/harness-error.js";
|
||||
import { validateBaseUrl, type AddressResolver } from "../models/base-url-policy.js";
|
||||
import { mediaRuntime, runMedia } from "../media/media-worker.js";
|
||||
import { validationSchema } from "./contracts.js";
|
||||
import { sha256 } from "./service.js";
|
||||
|
||||
export async function validateImageVideoUrl(value: string, resolver?: AddressResolver) {
|
||||
let url: URL;
|
||||
try { url = new URL(value); } catch { throw new HarnessError("I2V_URL_DENIED", "Invalid media URL"); }
|
||||
// The storage provider may require a signed query. Only remove it from host validation.
|
||||
const hostOnly = new URL(url); hostOnly.search = "";
|
||||
const checked = await validateBaseUrl(hostOnly.href, {}, resolver);
|
||||
const address = checked.addresses[0];
|
||||
if (!address) throw new HarnessError("I2V_URL_DENIED", "No public media address");
|
||||
return { url, address };
|
||||
}
|
||||
|
||||
/** HTTPS with DNS pinned to the validated address; redirects and oversized bodies fail closed. */
|
||||
export async function imageVideoTransfer(value: string, options: { bytes?: Buffer; contentType?: string; maxBytes?: number } = {}): Promise<Buffer> {
|
||||
const { url, address } = await validateImageVideoUrl(value);
|
||||
return new Promise((resolve, reject) => {
|
||||
const error = () => new HarnessError("I2V_TRANSFER_FAILED", "Media transfer failed or exceeded its bounds");
|
||||
const req = request(url, {
|
||||
family: isIP(address),
|
||||
method: options.bytes ? "PUT" : "GET",
|
||||
headers: options.bytes ? { "Content-Type": options.contentType ?? "application/octet-stream", "Content-Length": options.bytes.length } : {},
|
||||
lookup: (_hostname, _options, callback) => callback(null, address, isIP(address)),
|
||||
}, response => {
|
||||
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { response.destroy(); req.destroy(error()); return; }
|
||||
const limit = options.maxBytes ?? (options.bytes ? 1024 * 1024 : 256 * 1024 * 1024);
|
||||
if (Number(response.headers["content-length"] ?? 0) > limit) { response.destroy(); req.destroy(error()); return; }
|
||||
if (!options.bytes && !/^video\/mp4(?:;|$)/i.test(response.headers["content-type"] ?? "")) { response.destroy(); req.destroy(error()); return; }
|
||||
let total = 0; const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk: Buffer) => { total += chunk.length; if (total > limit) req.destroy(error()); else chunks.push(chunk); });
|
||||
response.once("error", () => reject(error()));
|
||||
response.once("end", () => { clearTimeout(timer); resolve(Buffer.concat(chunks)); });
|
||||
});
|
||||
const timer = setTimeout(() => req.destroy(error()), 120_000);
|
||||
req.once("error", () => { clearTimeout(timer); reject(error()); });
|
||||
req.end(options.bytes);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseImageVideoValidation(value: unknown, duration: number) {
|
||||
const raw = z.object({ video_seconds: z.number().positive(), audio_seconds: z.number().positive(), width: z.number().positive(), height: z.number().positive(), fps: z.string(), decoded: z.literal(true), sha256: z.string() }).parse(value);
|
||||
const parts = raw.fps.split("/"); const fps = Number(parts[0]) / Number(parts[1] ?? 1);
|
||||
if (Math.abs(raw.video_seconds - duration) > 0.002 || Math.abs(raw.audio_seconds - duration) > 0.05 || Math.abs(raw.width / raw.height - 9 / 16) > 0.01 || fps > 120) throw new HarnessError("I2V_VALIDATION_FAILED", "H3 output does not match approved duration or portrait dimensions");
|
||||
return validationSchema.parse({ videoSeconds: raw.video_seconds, audioSeconds: raw.audio_seconds, width: raw.width, height: raw.height, fps, decoded: true, sha256: raw.sha256, visualReview: "pending", audioReview: "pending" });
|
||||
}
|
||||
|
||||
export async function verifyImageVideo(path: string, directory: string, duration: number, options: { skillRoot: string; python: string }) {
|
||||
const { ffmpeg, ffprobe } = await mediaRuntime();
|
||||
// Patch the module object used by the unmodified original Skill. Every subprocess is
|
||||
// restricted, including ffprobe, full decode and the audio-peak ffmpeg invocation.
|
||||
const wrapper = `import runpy,sys,subprocess\noriginal=subprocess.run\nallowed=set(sys.argv[2:4])\ndef local_run(command,*args,**kwargs):\n if not isinstance(command,list) or command[0] not in allowed: raise RuntimeError('Unexpected media command')\n return original([command[0],'-protocol_whitelist','file,pipe','-format_whitelist','mov']+command[1:],*args,**kwargs)\nsubprocess.run=local_run\nscript=sys.argv[1]\nsys.argv=[script,'--ffmpeg',sys.argv[2],'--ffprobe',sys.argv[3],'verify','--input',sys.argv[4],'--minimum',sys.argv[5]]\nrunpy.run_path(script,run_name='__main__')`;
|
||||
const result: unknown = JSON.parse(await runMedia(options.python, ["-c", wrapper, join(options.skillRoot, "scripts/media.py"), ffmpeg, ffprobe, path, String(duration)], directory, new AbortController().signal, 180_000));
|
||||
return parseImageVideoValidation(result, duration);
|
||||
}
|
||||
|
||||
export function createImageVideoCollector(options: { skillRoot: string; python: string }) {
|
||||
return async (url: string, directory: string, duration: number) => {
|
||||
const bytes = await imageVideoTransfer(url);
|
||||
if (bytes.length < 16 || bytes.subarray(4, 8).toString("ascii") !== "ftyp") throw new HarnessError("I2V_INVALID_MP4", "Artifact is not an MP4 file");
|
||||
const path = join(directory, "candidate.mp4"); await writeFile(path, bytes, { mode: 0o600 });
|
||||
const validation = await verifyImageVideo(path, directory, duration, options);
|
||||
if (validation.sha256 !== sha256(await readFile(path))) throw new HarnessError("I2V_INVALID_MP4", "Validated media changed");
|
||||
await rename(path, join(directory, "video.mp4"));
|
||||
return validation;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, open, readFile, readdir, rename, stat } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { AttachmentStore } from "../attachments/attachment-store.js";
|
||||
import { HarnessError } from "../errors/harness-error.js";
|
||||
import { ImageVideoDraftSchema, recordSchema, taskIdSchema, validationSchema, type ImageVideoDraft, type ImageVideoPublicTask, type ImageVideoRecord, type ImageVideoRemote, type ImageVideoValidation } from "./contracts.js";
|
||||
|
||||
const locks = new Map<string, Promise<unknown>>();
|
||||
export const sha256 = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex");
|
||||
const fail = (code: string, message: string): never => { throw new HarnessError(code, message); };
|
||||
export type ImageVideoOptions = { root: string; attachments: Pick<AttachmentStore, "get" | "openNormalized">; remote: ImageVideoRemote; collect: (url: string, directory: string, duration: number) => Promise<ImageVideoValidation> };
|
||||
|
||||
/** One process owns this private store. Per-task locks are shared by service instances. */
|
||||
export class ImageVideoService {
|
||||
readonly root: string;
|
||||
constructor(private readonly options: ImageVideoOptions) { this.root = resolve(options.root); }
|
||||
|
||||
async create(sessionId: string, input: ImageVideoDraft): Promise<ImageVideoPublicTask> {
|
||||
const draft = ImageVideoDraftSchema.parse(input);
|
||||
const attachment = this.options.attachments.get(sessionId, draft.attachmentId);
|
||||
if (!attachment || attachment.sessionId !== sessionId) return fail("I2V_TASK_NOT_FOUND", "Reference image not found in this Session");
|
||||
const bytes = await this.options.attachments.openNormalized(sessionId, draft.attachmentId);
|
||||
const now = new Date().toISOString(); const id = `i2v_${randomUUID()}`;
|
||||
const record = recordSchema.parse({ version: 1, id, ownerSessionId: sessionId, revision: 0, draft, referenceHash: sha256(bytes), requestHash: sha256(JSON.stringify(draft)), referenceType: attachment.mediaType, state: "draft", createdAt: now, updatedAt: now });
|
||||
await mkdir(join(this.root, id), { recursive: true, mode: 0o700 });
|
||||
await this.atomic(join(this.root, id, "reference"), bytes);
|
||||
await this.atomic(join(this.root, id, "record.json"), JSON.stringify(record));
|
||||
return this.public(record);
|
||||
}
|
||||
|
||||
async list(): Promise<ImageVideoPublicTask[]> {
|
||||
const entries = await readdir(this.root).catch(() => []);
|
||||
const tasks = await Promise.all(entries.filter(id => taskIdSchema.safeParse(id).success).map(id => this.view(id).catch(() => undefined)));
|
||||
return tasks.filter((task): task is ImageVideoPublicTask => task !== undefined).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
async view(id: string): Promise<ImageVideoPublicTask> { return this.lock(id, async () => this.public(await this.read(id))); }
|
||||
async owned(sessionId: string, id: string): Promise<ImageVideoPublicTask> { return this.lock(id, async () => { const r = await this.read(id); this.owner(r, sessionId); return this.public(r); }); }
|
||||
|
||||
async upload(sessionId: string, id: string): Promise<ImageVideoPublicTask> {
|
||||
return this.lock(id, async () => {
|
||||
const r = await this.read(id); this.owner(r, sessionId);
|
||||
if (r.state === "unknown_upload") return fail("I2V_UNKNOWN_UPLOAD", "Upload outcome unknown; inspect the saved receipt before any new import");
|
||||
if (r.state !== "draft") return this.public(r);
|
||||
// Read-only discovery and identity validation happen before any remote mutation intent.
|
||||
r.identity = await this.options.remote.preflight();
|
||||
const bytes = await this.reference(r);
|
||||
r.state = "initializing_upload"; await this.save(r);
|
||||
try {
|
||||
r.upload = await this.options.remote.initialize(r, bytes); r.state = "uploading"; await this.save(r);
|
||||
await this.options.remote.put(r, bytes); r.state = "finalizing"; await this.save(r);
|
||||
await this.options.remote.finalize(r); r.state = "importing"; await this.save(r);
|
||||
} catch { r.state = "unknown_upload"; r.error = "参考图上传或入库结果未知;禁止重复导入。请核实本任务收据。"; await this.save(r); }
|
||||
return this.public(r);
|
||||
});
|
||||
}
|
||||
|
||||
async submit(sessionId: string, id: string): Promise<ImageVideoPublicTask> {
|
||||
return this.lock(id, async () => {
|
||||
const r = await this.read(id); this.owner(r, sessionId);
|
||||
if (r.jobId) return this.public(r);
|
||||
if (r.state === "unknown_submission") return fail("I2V_UNKNOWN_SUBMISSION", "Submission outcome unknown; no safe retry or new billing request is allowed");
|
||||
if (r.state !== "awaiting_submission" || !r.assetId) return fail("I2V_NOT_READY", "Reference import must be ready before generation approval");
|
||||
await this.options.remote.preflight(); await this.reference(r);
|
||||
r.state = "submitting"; await this.save(r);
|
||||
try {
|
||||
// Deliberately independent of chat cancellation: retain any returned receipt.
|
||||
const receipt = await this.options.remote.submit(r);
|
||||
if (!receipt.jobId) throw new Error("Missing job ID");
|
||||
await this.atomic(join(this.root, id, "submission-receipt.json"), JSON.stringify({ version: 1, id, revision: r.revision, requestHash: r.requestHash, jobId: receipt.jobId }));
|
||||
r.jobId = receipt.jobId; r.state = "queued"; delete r.lastPollAt; await this.save(r);
|
||||
} catch { r.state = "unknown_submission"; r.error = "生成提交结果未知;禁止重投。请人工核实,避免重复计费。"; await this.save(r); }
|
||||
return this.public(r);
|
||||
});
|
||||
}
|
||||
|
||||
async refresh(id: string): Promise<ImageVideoPublicTask> {
|
||||
return this.lock(id, async () => {
|
||||
const r = await this.read(id);
|
||||
if (r.state === "ready" || r.state === "failed" || Date.now() - (r.lastPollAt ?? 0) < 30_000) return this.public(r);
|
||||
if (!r.jobId && !(r.upload && ["importing", "unknown_upload"].includes(r.state))) return this.public(r);
|
||||
r.lastPollAt = Date.now(); await this.save(r);
|
||||
try {
|
||||
if (!r.jobId) {
|
||||
const result = await this.options.remote.importStatus(r);
|
||||
if (result.state === "ready" && result.assetId) { r.assetId = result.assetId; r.state = "awaiting_submission"; delete r.error; }
|
||||
else if (result.state === "failed") { r.state = "failed"; r.error = "参考图入库失败。"; }
|
||||
} else {
|
||||
const result = await this.options.remote.status(r);
|
||||
if (result.state === "succeeded") {
|
||||
if (!result.artifactUrl) throw new Error("No unambiguous MP4 artifact");
|
||||
r.artifactUrl = result.artifactUrl; r.state = "validating"; await this.save(r);
|
||||
r.validation = validationSchema.parse(await this.options.collect(result.artifactUrl, join(this.root, id), r.draft.durationSeconds));
|
||||
if (Math.abs(r.validation.videoSeconds - r.draft.durationSeconds) > 0.002 || Math.abs(r.validation.audioSeconds - r.draft.durationSeconds) > 0.05) throw new Error("Duration mismatch");
|
||||
r.state = "ready"; delete r.error;
|
||||
} else { r.state = result.state; if (result.state === "failed") r.error = "H3 生成失败;未自动重投。"; }
|
||||
}
|
||||
} catch {
|
||||
if (r.state === "validating") r.state = "failed";
|
||||
r.error = r.state === "failed" ? "成片技术验证失败:请核实 MP4、精确时长、尺寸、帧率和音轨;未自动重投。" : "状态响应暂不可用或契约不明确;保留原任务,下次只读查询。";
|
||||
}
|
||||
await this.save(r); return this.public(r);
|
||||
});
|
||||
}
|
||||
|
||||
async media(id: string, kind: "reference" | "video") {
|
||||
return this.lock(id, async () => {
|
||||
const r = await this.read(id);
|
||||
if (kind === "video" && (r.state !== "ready" || !r.validation)) return fail("I2V_MEDIA_NOT_FOUND", "Validated video not ready");
|
||||
const path = join(this.root, id, kind === "reference" ? "reference" : "video.mp4");
|
||||
await this.regular(path); const bytes = await readFile(path);
|
||||
if (sha256(bytes) !== (kind === "reference" ? r.referenceHash : r.validation?.sha256)) return fail("I2V_MEDIA_INVALID", "Registered media hash mismatch");
|
||||
return { path, size: (await stat(path)).size, contentType: kind === "reference" ? r.referenceType : "video/mp4" };
|
||||
});
|
||||
}
|
||||
private owner(r: ImageVideoRecord, sessionId: string) { if (r.ownerSessionId !== sessionId) fail("I2V_TASK_NOT_FOUND", "Task not found in this Session"); }
|
||||
private public(r: ImageVideoRecord): ImageVideoPublicTask {
|
||||
return { id: r.id, title: r.draft.title, prompt: r.draft.prompt, durationSeconds: r.draft.durationSeconds, state: r.state, createdAt: r.createdAt, updatedAt: r.updatedAt, referenceUrl: `/api/image-video/tasks/${r.id}/reference`, ...(r.jobId ? { jobId: r.jobId } : {}), ...(r.error ? { error: r.error } : {}), ...(r.validation ? { validation: r.validation } : {}), ...(r.state === "ready" ? { videoUrl: `/api/image-video/tasks/${r.id}/video` } : {}) };
|
||||
}
|
||||
private async reference(r: ImageVideoRecord) { await this.regular(join(this.root, r.id, "reference")); const bytes = await readFile(join(this.root, r.id, "reference")); if (sha256(bytes) !== r.referenceHash) fail("I2V_MEDIA_INVALID", "Reference changed after draft"); return bytes; }
|
||||
private async regular(path: string) { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink()) fail("I2V_RECORD_INVALID", "Unsafe registered file"); }
|
||||
private async read(id: string): Promise<ImageVideoRecord> {
|
||||
if (!taskIdSchema.safeParse(id).success) return fail("I2V_TASK_NOT_FOUND", "Task not found");
|
||||
const path = join(this.root, id, "record.json");
|
||||
const dir = await lstat(join(this.root, id)).catch(() => undefined);
|
||||
if (!dir || !dir.isDirectory() || dir.isSymbolicLink()) return fail("I2V_TASK_NOT_FOUND", "Task not found");
|
||||
try {
|
||||
await this.regular(path); const r = recordSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
||||
if (r.id !== id || r.requestHash !== sha256(JSON.stringify(r.draft))) throw new Error("Binding mismatch");
|
||||
if (r.state === "submitting") { r.state = "unknown_submission"; await this.save(r); }
|
||||
if (["initializing_upload", "uploading", "finalizing"].includes(r.state)) { r.state = "unknown_upload"; await this.save(r); }
|
||||
return r;
|
||||
} catch { return fail("I2V_RECORD_INVALID", "Task record is invalid; manual recovery required"); }
|
||||
}
|
||||
private async save(r: ImageVideoRecord) {
|
||||
const path = join(this.root, r.id, "record.json");
|
||||
const previous = recordSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
||||
if (previous.revision !== r.revision) return fail("I2V_STALE_REVISION", "Stale task result rejected");
|
||||
r.revision++; r.updatedAt = new Date().toISOString();
|
||||
await this.atomic(path, JSON.stringify(recordSchema.parse(r)));
|
||||
}
|
||||
private async atomic(path: string, content: Buffer | string) { const temp = `${path}.${randomUUID()}.tmp`; const file = await open(temp, "wx", 0o600); try { await file.writeFile(content); await file.sync(); } finally { await file.close(); } await rename(temp, path); }
|
||||
private async lock<T>(id: string, operation: () => Promise<T>): Promise<T> {
|
||||
const key = `${this.root}/${id}`; const previous = locks.get(key) ?? Promise.resolve();
|
||||
const work = previous.catch(() => undefined).then(operation); locks.set(key, work);
|
||||
try { return await work; } finally { if (locks.get(key) === work) locks.delete(key); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import type { ImageVideoService } from "./service.js";
|
||||
const task = Type.Object({ taskId: Type.String({ pattern: "^i2v_[0-9a-f-]{36}$" }) }, { additionalProperties: false });
|
||||
export function createImageVideoTools(service: ImageVideoService, sessionId: string) {
|
||||
const output = (details: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(details) }], details });
|
||||
return [
|
||||
defineTool({ name: "i2v_create_task", label: "Prepare local image video draft", description: "Read swads-image-to-video SKILL.md and references first. Copy this Session's existing attachment into a durable local draft. No external transmission. Use the user's exact title, prompt and duration; default portrait 9:16, H3 ref2va,768p. A form-created taskId already exists: do not create it again. Image display does not send it to the chat model. Never require CMS.", parameters: Type.Object({ attachmentId: Type.String(), title: Type.String(), prompt: Type.String(), durationSeconds: Type.Number() }, { additionalProperties: false }), async execute(_id, args) { return output(await service.create(sessionId, args)); } }),
|
||||
defineTool({ name: "i2v_upload_reference", label: "Approve reference transfer", description: "Separate explicit page approval: transfer this registered draft's frozen image to SW Ads storage and create review_pending, unassigned reference asset. No account assignment or publishing. Arguments accept only taskId. Refusal performs no transfer. Wait for import status before requesting generation approval.", parameters: task, async execute(_id, args) { return output(await service.upload(sessionId, args.taskId)); } }),
|
||||
defineTool({ name: "i2v_submit", label: "Approve H3 generation", description: "Separate explicit page approval: submit this registered task's exact frozen prompt, reference asset and target duration once to H3 ref2va,9:16,768p. Cost unknown; target duration support unverified. Never modify duration, retry unknown submission, or switch provider. Returns durable jobId immediately; UI polls the original job without an LLM. Technical preview is not human-reviewed.", parameters: task, async execute(_id, args) { return output(await service.submit(sessionId, args.taskId)); } }),
|
||||
defineTool({ name: "i2v_status", label: "Read registered image video task", description: "Read only this Session's registered task. Poll at most every 30 seconds; preserve queued or unknown state. Does not submit or approve. The webpage restores task history after restart.", parameters: task, async execute(_id, args) { await service.owned(sessionId, args.taskId); return output(await service.refresh(args.taskId)); } }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as harness from "../src/index.js";
|
||||
|
||||
describe("image video remote boundary", () => {
|
||||
it("rejects incompatible live tool input types and new required fields before calling them", () => {
|
||||
expect(harness).toHaveProperty("assertImageVideoArguments");
|
||||
const schema = { type: "object", properties: { duration_seconds: { type: "number" }, reference_asset_ids: { anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }] } }, required: ["duration_seconds"] };
|
||||
expect(() => harness.assertImageVideoArguments(schema, { duration_seconds: 10, reference_asset_ids: ["asset_1"] })).not.toThrow();
|
||||
expect(() => harness.assertImageVideoArguments(schema, { duration_seconds: "10" })).toThrow();
|
||||
expect(() => harness.assertImageVideoArguments(schema, { duration_seconds: 10, external_account_id: "unapproved" })).toThrow();
|
||||
expect(() => harness.assertImageVideoArguments({ ...schema, required: ["new_field"] }, { duration_seconds: 10 })).toThrow();
|
||||
});
|
||||
it("accepts signed HTTPS queries privately but rejects credentials, private DNS and localhost", async () => {
|
||||
expect(harness).toHaveProperty("validateImageVideoUrl");
|
||||
const result = await harness.validateImageVideoUrl("https://cdn.example/video?token=private", () => Promise.resolve([{ address: "8.8.8.8", family: 4 }]));
|
||||
expect(result.url.search).toBe("?token=private"); expect(result.address).toBe("8.8.8.8");
|
||||
for (const url of ["http://cdn.example/v", "https://user:pass@cdn.example/v", "https://127.0.0.1/v", "https://[::ffff:127.0.0.1]/v"]) await expect(harness.validateImageVideoUrl(url)).rejects.toBeDefined();
|
||||
await expect(harness.validateImageVideoUrl("https://cdn.example/v", () => Promise.resolve([{ address: "192.168.1.2", family: 4 }]))).rejects.toBeDefined();
|
||||
});
|
||||
it("extracts only the documented response envelope and rejects malformed or error responses", () => {
|
||||
expect(harness).toHaveProperty("imageVideoResponse");
|
||||
expect(harness.imageVideoResponse({ content: [{ type: "text", text: '{"job_id":"j1"}' }] })).toEqual({ job_id: "j1" });
|
||||
for (const value of [{ isError: true, content: [{ type: "text", text: "token=private" }] }, { content: [{ type: "text", text: "not JSON" }] }, { structuredContent: [] }]) expect(() => harness.imageVideoResponse(value)).toThrow();
|
||||
});
|
||||
it("refuses a false-duration or incomplete technical validation instead of releasing a video", () => {
|
||||
expect(harness).toHaveProperty("parseImageVideoValidation");
|
||||
const result = { video_seconds: 10, audio_seconds: 10, width: 720, height: 1280, fps: "24", decoded: true, sha256: "a".repeat(64) };
|
||||
expect(harness.parseImageVideoValidation(result, 10).visualReview).toBe("pending");
|
||||
for (const changed of [{ video_seconds: 7.292 }, { audio_seconds: 0 }, { decoded: false }, { width: 1280, height: 720 }, { fps: "0" }]) expect(() => harness.parseImageVideoValidation({ ...result, ...changed }, 10)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as harness from "../src/index.js";
|
||||
describe("image video tool permissions", () => {
|
||||
it("separates local draft/status from separately approved upload and generation", () => {
|
||||
expect(harness.decideToolPolicy("i2v_create_task")).toBe("allow");
|
||||
expect(harness.decideToolPolicy("i2v_status")).toBe("allow");
|
||||
expect(harness.decideToolPolicy("i2v_upload_reference")).toBe("ask");
|
||||
expect(harness.decideToolPolicy("i2v_submit")).toBe("ask");
|
||||
expect(harness.decideToolPolicy("video_h3_submit")).toBe("deny");
|
||||
expect(harness.decideToolPolicy("creative_upload_from_url")).toBe("deny");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
const response = vi.hoisted(() => ({ status: 200, type: "video/mp4", body: Buffer.from("mock-MP4"), connectedAddress: "" }));
|
||||
vi.mock("node:dns/promises", () => ({ lookup: () => Promise.resolve([{ address: "8.8.8.8", family: 4 }]) }));
|
||||
vi.mock("node:https", () => ({ request: (_url: URL, options: { family?: number; lookup: (name: string, options: { all?: boolean }, callback: (error: Error | null, addresses: string | { address: string }[]) => void) => void }, receive: (value: EventEmitter) => void) => {
|
||||
const req = new EventEmitter() as EventEmitter & { end: () => void; destroy: (error?: Error) => void };
|
||||
let destroyed = false;
|
||||
req.destroy = (error) => { destroyed = true; if (error) queueMicrotask(() => req.emit("error", error)); };
|
||||
req.end = () => queueMicrotask(() => {
|
||||
// Node 22 requests all addresses unless a concrete address family is supplied.
|
||||
const all = options.family !== 4 && options.family !== 6;
|
||||
options.lookup("cdn.example", { all }, (error, addresses) => {
|
||||
if (error || (all && !Array.isArray(addresses))) { req.destroy(new Error("Invalid IP callback shape")); return; }
|
||||
response.connectedAddress = typeof addresses === "string" ? addresses : addresses[0]?.address ?? "";
|
||||
const incoming = Object.assign(new EventEmitter(), { statusCode: response.status, headers: { "content-type": response.type, "content-length": response.body.length }, destroy() {} });
|
||||
receive(incoming); if (!destroyed) { incoming.emit("data", response.body); incoming.emit("end"); }
|
||||
});
|
||||
});
|
||||
return req;
|
||||
} }));
|
||||
import { imageVideoTransfer } from "../src/image-video/media.js";
|
||||
describe("bounded pinned HTTPS media transfer", () => {
|
||||
it("connects with the checked public IP using Node's actual lookup callback contract", async () => {
|
||||
response.status = 200; response.type = "video/mp4";
|
||||
expect(await imageVideoTransfer("https://cdn.example/video?token=private")).toEqual(Buffer.from("mock-MP4"));
|
||||
expect(response.connectedAddress).toBe("8.8.8.8");
|
||||
});
|
||||
it("rejects redirects, HTML masquerading as a video and oversized bodies", async () => {
|
||||
response.status = 302; await expect(imageVideoTransfer("https://cdn.example/video")).rejects.toThrow();
|
||||
response.status = 200; response.type = "text/html"; await expect(imageVideoTransfer("https://cdn.example/video")).rejects.toThrow();
|
||||
response.type = "video/mp4"; await expect(imageVideoTransfer("https://cdn.example/video", { maxBytes: 2 })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as harness from "../src/index.js";
|
||||
import type { ImageVideoRemote, ImageVideoRecord } from "../src/image-video/contracts.js";
|
||||
|
||||
async function fixture() {
|
||||
expect(harness).toHaveProperty("ImageVideoService");
|
||||
const root = await mkdtemp(join(tmpdir(), "i2v-test-"));
|
||||
await mkdir(join(root, "ses_owner"));
|
||||
const attachments = new harness.FileAttachmentStore({ sessionsRoot: root });
|
||||
const attachment = await attachments.add("ses_owner", { name: "test.png", mediaType: "image/png", stream: Readable.from(await sharp({ create: { width: 8, height: 8, channels: 3, background: "red" } }).png().toBuffer()) });
|
||||
const calls: string[] = [];
|
||||
const remote: ImageVideoRemote = {
|
||||
preflight() { calls.push("identity"); return Promise.resolve({ tenantId: "tenant", platform: "tiktok" }); },
|
||||
initialize() { calls.push("initialize"); return Promise.resolve({ uploadId: "upload_1", objectKey: "obj", uploadUrl: "https://public.example/put?token=private" }); },
|
||||
put() { calls.push("put"); return Promise.resolve(); }, finalize() { calls.push("finalize"); return Promise.resolve(); },
|
||||
importStatus() { calls.push("import"); return Promise.resolve({ state: "ready", assetId: "asset_1" }); },
|
||||
submit() { calls.push("submit"); return Promise.resolve({ jobId: "remote_job_1" }); },
|
||||
status() { calls.push("status"); return Promise.resolve({ state: "queued" }); },
|
||||
};
|
||||
const options = { root: join(root, "tasks"), attachments, remote, collect: () => Promise.reject(new Error("No fake video")) };
|
||||
const service = new harness.ImageVideoService(options);
|
||||
const draft = { attachmentId: attachment.attachmentId, title: "Motion", prompt: "Natural movement with ambient sound", durationSeconds: 10 };
|
||||
return { service, options, draft, calls, remote, root };
|
||||
}
|
||||
|
||||
describe("durable image video tasks", () => {
|
||||
it("copies a local reference without transmitting it and hides private bindings", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
expect(f.calls).toEqual([]); expect(task.state).toBe("draft");
|
||||
await f.options.attachments.removeAll("ses_owner");
|
||||
expect((await f.service.media(task.id, "reference")).size).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(await f.service.list())).not.toMatch(/ownerSessionId|uploadUrl|objectKey|referenceHash|\/private\//);
|
||||
});
|
||||
it("rejects foreign owners before any external operation", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
await expect(f.service.upload("ses_other", task.id)).rejects.toMatchObject({ code: "I2V_TASK_NOT_FOUND" });
|
||||
await expect(f.service.submit("ses_other", task.id)).rejects.toMatchObject({ code: "I2V_TASK_NOT_FOUND" });
|
||||
expect(f.calls).toEqual([]);
|
||||
});
|
||||
it("requires upload first and coalesces concurrent submission, restoring only the known job", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
await expect(f.service.submit("ses_owner", task.id)).rejects.toMatchObject({ code: "I2V_NOT_READY" });
|
||||
await f.service.upload("ses_owner", task.id); await f.service.refresh(task.id);
|
||||
const [a, b] = await Promise.all([f.service.submit("ses_owner", task.id), f.service.submit("ses_owner", task.id)]);
|
||||
expect(a.jobId).toBe("remote_job_1"); expect(b.jobId).toBe(a.jobId);
|
||||
const restored = new harness.ImageVideoService(f.options);
|
||||
expect((await restored.view(task.id)).jobId).toBe(a.jobId);
|
||||
await restored.submit("ses_owner", task.id);
|
||||
expect(f.calls.filter(call => call === "submit")).toHaveLength(1);
|
||||
expect(JSON.stringify(await restored.view(task.id))).not.toContain("private");
|
||||
});
|
||||
it("isolates unknown submissions after timeout and after process interruption", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
await f.service.upload("ses_owner", task.id); await f.service.refresh(task.id);
|
||||
f.remote.submit = () => { f.calls.push("submit"); return Promise.reject(new Error("token=private")); };
|
||||
expect((await f.service.submit("ses_owner", task.id)).state).toBe("unknown_submission");
|
||||
await expect(new harness.ImageVideoService(f.options).submit("ses_owner", task.id)).rejects.toMatchObject({ code: "I2V_UNKNOWN_SUBMISSION" });
|
||||
expect(f.calls.filter(call => call === "submit")).toHaveLength(1);
|
||||
const file = join(f.options.root, task.id, "record.json"); const record = JSON.parse(await readFile(file, "utf8")) as ImageVideoRecord;
|
||||
record.state = "submitting"; await writeFile(file, JSON.stringify(record));
|
||||
expect((await new harness.ImageVideoService(f.options).view(task.id)).state).toBe("unknown_submission");
|
||||
});
|
||||
it("never reinitializes uncertain uploads and blocks stale persisted revisions", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
f.remote.initialize = () => { f.calls.push("initialize"); return Promise.reject(new Error("timeout")); };
|
||||
expect((await f.service.upload("ses_owner", task.id)).state).toBe("unknown_upload");
|
||||
await expect(f.service.upload("ses_owner", task.id)).rejects.toMatchObject({ code: "I2V_UNKNOWN_UPLOAD" });
|
||||
expect(f.calls.filter(call => call === "initialize")).toHaveLength(1);
|
||||
const file = join(f.options.root, task.id, "record.json"); const record = JSON.parse(await readFile(file, "utf8")) as ImageVideoRecord;
|
||||
record.requestHash = "invalid"; await writeFile(file, JSON.stringify(record));
|
||||
await expect(f.service.view(task.id)).rejects.toMatchObject({ code: "I2V_RECORD_INVALID" });
|
||||
});
|
||||
it("throttles read-only polling even across instances and accepts no arbitrary identifiers", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
await f.service.upload("ses_owner", task.id); await f.service.refresh(task.id); await f.service.submit("ses_owner", task.id);
|
||||
await Promise.all([f.service.refresh(task.id), new harness.ImageVideoService(f.options).refresh(task.id)]);
|
||||
expect(f.calls.filter(call => call === "status")).toHaveLength(1);
|
||||
await expect(f.service.view("../../etc/passwd")).rejects.toMatchObject({ code: "I2V_TASK_NOT_FOUND" });
|
||||
});
|
||||
it("rejects a late submit result from overwriting a newer revision, while retaining its known receipt", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
await f.service.upload("ses_owner", task.id); await f.service.refresh(task.id);
|
||||
f.remote.submit = async () => {
|
||||
const file = join(f.options.root, task.id, "record.json"); const newer = JSON.parse(await readFile(file, "utf8")) as ImageVideoRecord;
|
||||
newer.revision += 1; newer.state = "unknown_submission"; await writeFile(file, JSON.stringify(newer));
|
||||
return { jobId: "late_job" };
|
||||
};
|
||||
await expect(f.service.submit("ses_owner", task.id)).rejects.toMatchObject({ code: "I2V_STALE_REVISION" });
|
||||
expect((await f.service.view(task.id)).state).toBe("unknown_submission");
|
||||
const receipt = JSON.parse(await readFile(join(f.options.root, task.id, "submission-receipt.json"), "utf8")) as { jobId: string };
|
||||
expect(receipt.jobId).toBe("late_job");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user