aimakeall-mcp 0.14.2 → 0.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/lib/api-client.mjs +21 -4
- package/lib/cloud-tools.mjs +11 -6
- package/lib/config.mjs +1 -1
- package/lib/durable-channel-client.mjs +30 -0
- package/lib/durable-channel-contract.mjs +60 -0
- package/lib/durable-channel-journal.mjs +53 -0
- package/lib/durable-channel-request.mjs +375 -0
- package/lib/durable-speech.mjs +16 -2
- package/lib/review-full-analysis.mjs +184 -21
- package/lib/review-native-job-request.mjs +329 -0
- package/lib/review-story-job-request.mjs +131 -0
- package/lib/review-tools.mjs +4 -4
- package/lib/speech-production-profile.mjs +21 -0
- package/lib/tts-alignment.mjs +69 -0
- package/package.json +1 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Shared browser/MCP one-way native-media transport. A stored attempt is never
|
|
2
|
+
// permission to upload again; a submitted paid request is only ever read again.
|
|
3
|
+
export const REVIEW_NATIVE_TRANSPORT = "review-native-queue-v2";
|
|
4
|
+
export const REVIEW_NATIVE_CAPABILITY = "/api/tracker/review/native-execution";
|
|
5
|
+
export const REVIEW_NATIVE_ATTEMPTS = "/api/tracker/review/media-attempts";
|
|
6
|
+
export const REVIEW_NATIVE_CHUNK_JOBS = "/api/tracker/review/chunk-jobs";
|
|
7
|
+
export const REVIEW_NATIVE_EVIDENCE_JOBS = "/api/tracker/review/evidence-jobs";
|
|
8
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
|
|
9
|
+
const ASSET = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
|
|
10
|
+
const HASH = /^[a-f0-9]{64}$/u, JOB = /^epoch-v2-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
|
|
11
|
+
const MAX_VIDEO = 10 * 1024 * 1024, MAX_TEXT = 4 * 1024 * 1024;
|
|
12
|
+
const encoder = new TextEncoder(), knownFailures = new WeakMap();
|
|
13
|
+
// Native ingress admits one request per owner. Serialize actual I/O (not whole
|
|
14
|
+
// queued jobs) across client instances. An adapter ignoring AbortSignal keeps
|
|
15
|
+
// its slot until it truly settles; a timed-out waiter never dispatches later.
|
|
16
|
+
const requestLanes = new Map();
|
|
17
|
+
function serializedRequest(scope, authority, check, callback) {
|
|
18
|
+
let owners = requestLanes.get(scope);
|
|
19
|
+
if (!owners) { owners = new Map(); requestLanes.set(scope, owners); }
|
|
20
|
+
const previous = owners.get(authority) || Promise.resolve();
|
|
21
|
+
const work = previous.then(() => { check(); return callback(); });
|
|
22
|
+
const settled = work.then(() => {}, () => {}).finally(() => {
|
|
23
|
+
if (owners.get(authority) === settled) owners.delete(authority);
|
|
24
|
+
if (!owners.size && requestLanes.get(scope) === owners) requestLanes.delete(scope);
|
|
25
|
+
});
|
|
26
|
+
owners.set(authority, settled); return work;
|
|
27
|
+
}
|
|
28
|
+
const stages = Object.freeze({ analyzing: { path: REVIEW_NATIVE_CHUNK_JOBS, type: "review:analyze-chunk", kind: "review-chunk", field: "chunkAnalysis", legacy: "analyze-chunk" },
|
|
29
|
+
verifying: { path: REVIEW_NATIVE_EVIDENCE_JOBS, type: "review:verify-evidence", kind: "review-evidence", field: "verification", legacy: "verify-evidence" } });
|
|
30
|
+
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
31
|
+
const failure = (code, message = "원본 분석 기록을 안전하게 확인하지 못했습니다. 자동으로 다시 생성하지 않습니다.") => Object.assign(new Error(message), { code, paidReplayAllowed: false, automaticUploadRetryAllowed: false });
|
|
32
|
+
function freeze(value) { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } return value; }
|
|
33
|
+
function fields(value, allowed, required = []) {
|
|
34
|
+
if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
35
|
+
const descriptors = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(descriptors);
|
|
36
|
+
if (keys.some(key => !allowed.includes(key) || !("value" in descriptors[key]) || !descriptors[key].enumerable)
|
|
37
|
+
|| required.some(key => !Object.hasOwn(descriptors, key))) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
38
|
+
return Object.fromEntries(keys.map(key => [key, descriptors[key].value]));
|
|
39
|
+
}
|
|
40
|
+
function capture(value, maximum = MAX_TEXT) {
|
|
41
|
+
let nodes = 0, bytes = 0; const ancestors = new Set();
|
|
42
|
+
const add = size => { if ((bytes += size) > maximum) throw failure("REVIEW_NATIVE_INPUT_LIMIT"); };
|
|
43
|
+
function copy(item, depth) {
|
|
44
|
+
if (++nodes > 400000 || depth > 24) throw failure("REVIEW_NATIVE_INPUT_LIMIT");
|
|
45
|
+
if (item === null || typeof item === "boolean") { add(JSON.stringify(item).length); return item; }
|
|
46
|
+
if (typeof item === "number" && Number.isFinite(item)) { add(JSON.stringify(item).length); return item; }
|
|
47
|
+
if (typeof item === "string") {
|
|
48
|
+
if (item.length > maximum || !item.isWellFormed()) throw failure("REVIEW_NATIVE_INPUT_LIMIT");
|
|
49
|
+
add(encoder.encode(JSON.stringify(item)).length); return item;
|
|
50
|
+
}
|
|
51
|
+
if (!item || typeof item !== "object" || ancestors.has(item)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
52
|
+
const array = Array.isArray(item), descriptors = Object.getOwnPropertyDescriptors(item), keys = Reflect.ownKeys(descriptors);
|
|
53
|
+
if (array ? Object.getPrototypeOf(item) !== Array.prototype : ![Object.prototype, null].includes(Object.getPrototypeOf(item))) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
54
|
+
if (keys.some(key => typeof key !== "string" || !("value" in descriptors[key]) || key.length > 128 || ["__proto__", "prototype", "constructor", "toJSON"].includes(key)
|
|
55
|
+
|| key !== "length" && !descriptors[key].enumerable)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
56
|
+
ancestors.add(item); add(2); let result;
|
|
57
|
+
if (array) {
|
|
58
|
+
if (item.length > 60000 || keys.length !== item.length + 1) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
59
|
+
result = Array.from({ length: item.length }, (_, index) => { if (!Object.hasOwn(descriptors, index)) throw failure("REVIEW_NATIVE_INPUT_INVALID"); if (index) add(1); return copy(descriptors[index].value, depth + 1); });
|
|
60
|
+
} else result = Object.fromEntries(keys.map((key, index) => { add(encoder.encode(JSON.stringify(key)).length + 1 + (index ? 1 : 0)); return [key, copy(descriptors[key].value, depth + 1)]; }));
|
|
61
|
+
ancestors.delete(item); return result;
|
|
62
|
+
}
|
|
63
|
+
return freeze(copy(value, 0));
|
|
64
|
+
}
|
|
65
|
+
function scopeOf(value) {
|
|
66
|
+
let parsed; try { parsed = new URL(value); } catch { throw failure("REVIEW_NATIVE_SCOPE_INVALID"); }
|
|
67
|
+
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) throw failure("REVIEW_NATIVE_SCOPE_INVALID");
|
|
68
|
+
return parsed.href.replace(/\/$/u, "");
|
|
69
|
+
}
|
|
70
|
+
const text = (value, maximum, empty = true) => {
|
|
71
|
+
if (typeof value !== "string" || value.length > maximum || !value.isWellFormed() || (!empty && !value.trim()) || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
72
|
+
return value;
|
|
73
|
+
};
|
|
74
|
+
function metadata(value) {
|
|
75
|
+
const keys = ["version", "sourceId", "chunkId", "startMs", "endMs", "sourceDurationMs", "hasAudio", "videoId", "workTitle", "mimeType", "actualDurationSec"];
|
|
76
|
+
const raw = fields(value, keys, keys.filter(key => key !== "actualDurationSec"));
|
|
77
|
+
if (raw.version !== 1 || raw.mimeType !== "video/mp4" || typeof raw.sourceId !== "string" || !/^[A-Za-z0-9_.:-]{1,90}$/u.test(raw.sourceId)
|
|
78
|
+
|| !Number.isSafeInteger(raw.sourceDurationMs) || raw.sourceDurationMs < 1 || raw.sourceDurationMs > 86400000
|
|
79
|
+
|| !Number.isSafeInteger(raw.startMs) || !Number.isSafeInteger(raw.endMs) || raw.startMs < 0 || raw.endMs <= raw.startMs || raw.endMs > raw.sourceDurationMs
|
|
80
|
+
|| raw.endMs - raw.startMs > 300000 || raw.chunkId !== `${raw.sourceId}:${raw.startMs}-${raw.endMs}` || typeof raw.hasAudio !== "boolean"
|
|
81
|
+
|| typeof raw.videoId !== "string" || raw.videoId !== "" && !/^[A-Za-z0-9_-]{11}$/u.test(raw.videoId)
|
|
82
|
+
|| text(raw.workTitle, 200) !== raw.workTitle.trim() || raw.actualDurationSec !== undefined && (!Number.isFinite(raw.actualDurationSec) || raw.actualDurationSec <= 0 || Math.abs(raw.actualDurationSec * 1000 - raw.endMs + raw.startMs) > 250)) throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
83
|
+
return freeze(Object.fromEntries(keys.filter(key => Object.hasOwn(raw, key)).map(key => [key, raw[key]])));
|
|
84
|
+
}
|
|
85
|
+
function mediaIdentity(value, reference = false) {
|
|
86
|
+
const keys = reference ? ["assetId", "namespace", "metadata", "sha256", "byteLength", "inputHash", "expiresAt"] : ["attemptId", "metadata", "sha256", "byteLength", "inputHash"];
|
|
87
|
+
const raw = fields(value, keys, keys);
|
|
88
|
+
if (!HASH.test(raw.sha256 || "") || !HASH.test(raw.inputHash || "") || !Number.isSafeInteger(raw.byteLength) || raw.byteLength < 16 || raw.byteLength > MAX_VIDEO
|
|
89
|
+
|| reference && (!ASSET.test(raw.assetId || "") || !HASH.test(raw.namespace || "") || !Number.isSafeInteger(raw.expiresAt) || raw.expiresAt <= 0)
|
|
90
|
+
|| !reference && !UUID.test(raw.attemptId || "")) throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
91
|
+
return freeze({ ...raw, metadata: metadata(raw.metadata) });
|
|
92
|
+
}
|
|
93
|
+
/** Compact allowlist suitable for IndexedDB/MCP checkpoint files. No tokens,
|
|
94
|
+
* paths, source handles, source URL, raw media or server-owned keys are allowed. */
|
|
95
|
+
export function captureReviewNativeCheckpoint(value) {
|
|
96
|
+
const raw = fields(value, ["transport", "apiScope", "mediaAttempt", "mediaReference", "submissionAttempted", "requestBinding", "jobId"]);
|
|
97
|
+
if (raw.transport !== undefined && raw.transport !== REVIEW_NATIVE_TRANSPORT || raw.submissionAttempted !== undefined && typeof raw.submissionAttempted !== "boolean"
|
|
98
|
+
|| raw.requestBinding !== undefined && !HASH.test(raw.requestBinding) || raw.jobId !== undefined && !JOB.test(raw.jobId)) throw failure("REVIEW_NATIVE_CHECKPOINT_INVALID");
|
|
99
|
+
return freeze({ ...raw, ...(raw.apiScope === undefined ? {} : { apiScope: scopeOf(raw.apiScope) }),
|
|
100
|
+
...(raw.mediaAttempt === undefined ? {} : { mediaAttempt: mediaIdentity(raw.mediaAttempt) }),
|
|
101
|
+
...(raw.mediaReference === undefined ? {} : { mediaReference: mediaIdentity(raw.mediaReference, true) }) });
|
|
102
|
+
}
|
|
103
|
+
const digest = async value => [...new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", typeof value === "string" ? encoder.encode(value) : value))].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
|
104
|
+
async function checkIdentity(identity) {
|
|
105
|
+
if (await digest(JSON.stringify({ version: 1, metadata: identity.metadata, sha256: identity.sha256, byteLength: identity.byteLength })) !== identity.inputHash) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
106
|
+
}
|
|
107
|
+
function uploadShape(value) {
|
|
108
|
+
const raw = fields(value, ["source", "chunk", "workTitle", "priorContext"], ["source", "chunk"]);
|
|
109
|
+
const source = fields(raw.source, ["id", "videoId", "kind", "title", "durationSec", "hasAudio"], ["id", "durationSec"]);
|
|
110
|
+
const chunk = fields(raw.chunk, ["chunkId", "sourceId", "startMs", "endMs", "mimeType", "videoBase64", "sha256", "hasAudio", "actualDurationSec"], ["chunkId", "startMs", "endMs", "mimeType", "videoBase64", "sha256", "hasAudio"]);
|
|
111
|
+
if (!Number.isFinite(source.durationSec) || source.durationSec <= 0 || source.durationSec > 86400 || source.kind !== undefined && !["local", "youtube"].includes(source.kind)
|
|
112
|
+
|| chunk.sourceId !== undefined && chunk.sourceId !== source.id || source.hasAudio !== undefined && source.hasAudio !== chunk.hasAudio) throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
113
|
+
if (source.title !== undefined) text(source.title, 200);
|
|
114
|
+
const meta = metadata({ version: 1, sourceId: source.id, chunkId: chunk.chunkId, startMs: chunk.startMs, endMs: chunk.endMs,
|
|
115
|
+
sourceDurationMs: Math.round(source.durationSec * 1000), hasAudio: chunk.hasAudio, videoId: (source.videoId ?? "").trim(),
|
|
116
|
+
workTitle: text(raw.workTitle ?? source.title ?? "", 200).trim(), mimeType: chunk.mimeType,
|
|
117
|
+
...(chunk.actualDurationSec === undefined ? {} : { actualDurationSec: chunk.actualDurationSec }) });
|
|
118
|
+
if (typeof chunk.videoBase64 !== "string" || chunk.videoBase64.length > 4 * Math.ceil(MAX_VIDEO / 3) || !chunk.videoBase64.length || chunk.videoBase64.length % 4 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(chunk.videoBase64) || !HASH.test(chunk.sha256 || "")) throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
119
|
+
let bytes; try { const binary = atob(chunk.videoBase64); if (btoa(binary) !== chunk.videoBase64) throw new Error(); bytes = Uint8Array.from(binary, char => char.charCodeAt(0)); } catch { throw failure("REVIEW_NATIVE_MEDIA_INVALID"); }
|
|
120
|
+
if (bytes.length < 16 || bytes.length > MAX_VIDEO || new TextDecoder().decode(bytes.slice(4, 8)) !== "ftyp") throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
121
|
+
return { input: { source, chunk, ...(raw.workTitle === undefined ? {} : { workTitle: raw.workTitle }) }, metadata: meta, bytes, sha256: chunk.sha256, priorContext: text(raw.priorContext ?? "", 12000).trim() };
|
|
122
|
+
}
|
|
123
|
+
function safeReferences(value) {
|
|
124
|
+
if (Array.isArray(value)) { for (const item of value) safeReferences(item); return; }
|
|
125
|
+
if (!object(value)) { if (typeof value === "string" && /^\s*(?:data|blob|file):/iu.test(value)) throw failure("REVIEW_NATIVE_INPUT_INVALID"); return; }
|
|
126
|
+
for (const [key, child] of Object.entries(value)) {
|
|
127
|
+
if (/^(?:owneruserid|ownerid|userid|inputhash|namespace|assetid|localpath|filepath|path|filename|url|asseturl|objectkey|bucket|videobase64|audiobase64|base64data|dataurl|buffer|credentials|cookie|sessionid|sessiontoken|token|secret|byok)$/u.test(key.replace(/[^a-z0-9]/giu, "").toLowerCase())
|
|
128
|
+
|| /apikey|authorization|accesstoken|refreshtoken|clientsecret|password|privatekey/iu.test(key)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
129
|
+
safeReferences(child);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function logicalInput(stage, body, identity) {
|
|
133
|
+
if (body === null) return null;
|
|
134
|
+
if (stage === "analyzing") {
|
|
135
|
+
fields(body, ["source", "chunk", "workTitle", "priorContext"]);
|
|
136
|
+
return { priorContext: text(body.priorContext ?? "", 12000).trim() };
|
|
137
|
+
}
|
|
138
|
+
const raw = fields(body, ["chunk", "chunkId", "chunks", "story"], ["chunks", "story"]);
|
|
139
|
+
const chunkId = raw.chunkId ?? raw.chunk?.chunk?.chunkId ?? raw.chunk?.chunkId;
|
|
140
|
+
if (chunkId !== identity.metadata.chunkId || !Array.isArray(raw.chunks) || raw.chunks.length < 1 || raw.chunks.length > 500 || !object(raw.story)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
141
|
+
safeReferences({ chunks: raw.chunks, story: raw.story });
|
|
142
|
+
const matches = raw.chunks.filter(item => item?.chunkId === chunkId), receipt = matches[0];
|
|
143
|
+
if (matches.length !== 1 || receipt.sha256 !== identity.sha256 || ["sourceId", "startMs", "endMs", "sourceDurationMs", "hasAudio", "videoId", "workTitle"].some(key => receipt[key] !== identity.metadata[key])) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
144
|
+
if (encoder.encode(JSON.stringify({ chunks: raw.chunks, story: raw.story })).length > MAX_TEXT) throw failure("REVIEW_NATIVE_INPUT_LIMIT");
|
|
145
|
+
return { chunkId, chunks: raw.chunks, story: raw.story };
|
|
146
|
+
}
|
|
147
|
+
/** Only the actual HTTP adapters attach typed unknown-submission metadata. */
|
|
148
|
+
export function attachReviewNativeJobFailureMetadata(error, payload) {
|
|
149
|
+
if (!object(error) || !object(payload) || payload.submissionState !== "unknown" || payload.retryAllowed !== false || payload.paidReplayAllowed !== false
|
|
150
|
+
|| !UUID.test(payload.requestId || "") || !JOB.test(payload.jobId || "") || payload.recoveryAction !== "read-known-job") return false;
|
|
151
|
+
const stage = Object.keys(stages).find(key => payload.statusPath === `${stages[key].path}/${payload.jobId}`);
|
|
152
|
+
if (!stage) return false;
|
|
153
|
+
knownFailures.set(error, { stage, requestId: payload.requestId, jobId: payload.jobId }); return true;
|
|
154
|
+
}
|
|
155
|
+
function jobFrom(payload, stage, requestId, jobId) {
|
|
156
|
+
const raw = capture(payload, 8 * 1024 * 1024 + 4096), job = raw.job;
|
|
157
|
+
if (raw.ok !== true || raw.async !== true || raw.requestId !== requestId || !object(job) || !JOB.test(job.id || "") || jobId && jobId !== job.id
|
|
158
|
+
|| job.type !== stages[stage].type || !["queued", "pending", "running", "completed", "failed", "cancelled", "unknown", "interrupted", "cancel_requested"].includes(job.status)
|
|
159
|
+
|| job.workflow?.paidReplayAllowed !== false || job.workflow?.recovery !== "read-only-known-job") throw failure("REVIEW_NATIVE_RESPONSE_INVALID");
|
|
160
|
+
return job;
|
|
161
|
+
}
|
|
162
|
+
export function unwrapReviewNativeJobResult(stage, value, suppliedReference) {
|
|
163
|
+
if (!stages[stage]) throw failure("REVIEW_NATIVE_STAGE_INVALID");
|
|
164
|
+
const ref = mediaIdentity(suppliedReference, true), result = capture(value, 8 * 1024 * 1024), native = result[stages[stage].field];
|
|
165
|
+
if (result.kind !== stages[stage].kind || result.assetId !== ref.assetId || result.namespace !== ref.namespace || result.mediaInputHash !== ref.inputHash
|
|
166
|
+
|| result.accountingState !== "settled" || result.paidReplayAllowed !== false || result.hasAccountCostEvents !== true || !object(native)
|
|
167
|
+
|| native.chunkId !== ref.metadata.chunkId || !Array.isArray(result.accountCostEvents) || !result.accountCostEvents.length
|
|
168
|
+
|| !Array.isArray(result.acceptedTaskCosts) || !object(result.usage) || !Number.isFinite(result.costUsd) || result.costUsd < 0
|
|
169
|
+
|| typeof result.operationId !== "string" || !new RegExp(`^bg-review-${stage === "analyzing" ? "chunk" : "evidence"}-v1:[a-f0-9]{64}$`, "u").test(result.operationId)
|
|
170
|
+
|| result.accountCostEvents.some((event, index) => event.accountCostEventId !== `${result.operationId}:${index + 1}` || event.accountCostRecorded !== true && event.accountCostSuppressedByReset !== true)) throw failure("REVIEW_NATIVE_RESULT_INVALID");
|
|
171
|
+
if (stage === "analyzing" && (native.sha256 !== ref.sha256 || ["sourceId", "startMs", "endMs", "sourceDurationMs", "hasAudio", "videoId", "workTitle"].some(key => native[key] !== ref.metadata[key]))) throw failure("REVIEW_NATIVE_RESULT_MISMATCH");
|
|
172
|
+
if (stage === "verifying" && (native.verificationKind !== "native-video-audio-recheck" || native.coverage?.startMs !== ref.metadata.startMs || native.coverage?.endMs !== ref.metadata.endMs
|
|
173
|
+
|| native.coverage?.nativeAudioSubmitted !== ref.metadata.hasAudio || native.selectedSourceIds?.length !== 1 || native.selectedSourceIds[0] !== ref.metadata.sourceId)) throw failure("REVIEW_NATIVE_RESULT_MISMATCH");
|
|
174
|
+
return freeze({ ...native, ...Object.fromEntries(["operationId", "accountCostEvents", "acceptedTaskCosts", "hasAccountCostEvents", "accountingState", "costUsd", "usage", "model", "provider", "generatedAt", "paidReplayAllowed", "requiresHumanReview"].filter(key => result[key] !== undefined).map(key => [key, result[key]])) });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createReviewNativeJobClient({ request, getAuthority, apiScope, pollMs = 1000, readTimeoutMs = 30000,
|
|
178
|
+
now = Date.now, uuid = () => globalThis.crypto.randomUUID(), setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
179
|
+
if (typeof request !== "function" || typeof getAuthority !== "function" || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 10000
|
|
180
|
+
|| !Number.isSafeInteger(readTimeoutMs) || readTimeoutMs < 1 || readTimeoutMs > 60000) throw new TypeError("Native review transport dependencies required");
|
|
181
|
+
const scope = scopeOf(apiScope);
|
|
182
|
+
async function run(stage, body, options = {}) {
|
|
183
|
+
if (!stages[stage]) throw failure("REVIEW_NATIVE_STAGE_INVALID");
|
|
184
|
+
// Capture descriptors and all input values before the first await, including
|
|
185
|
+
// before crypto hashing, capability discovery or journal persistence.
|
|
186
|
+
const captured = body == null ? null : capture(body, 4 * Math.ceil(MAX_VIDEO / 3) + MAX_TEXT + 32768);
|
|
187
|
+
const checkpoint = captureReviewNativeCheckpoint(Object.fromEntries(["transport", "apiScope", "mediaAttempt", "mediaReference", "submissionAttempted", "requestBinding", "jobId"].filter(key => options[key] !== undefined && options[key] !== "").map(key => [key, options[key]])));
|
|
188
|
+
const { requestId, onCheckpoint, signal } = options, authority = getAuthority();
|
|
189
|
+
if (!authority) throw failure("REVIEW_NATIVE_AUTHORITY_REQUIRED");
|
|
190
|
+
if (!UUID.test(requestId || "")) throw failure("REVIEW_NATIVE_ID_INVALID");
|
|
191
|
+
const recovering = checkpoint.transport !== undefined || checkpoint.mediaAttempt !== undefined || checkpoint.submissionAttempted !== undefined || checkpoint.jobId !== undefined;
|
|
192
|
+
if (recovering && (checkpoint.transport !== REVIEW_NATIVE_TRANSPORT || !checkpoint.apiScope || checkpoint.submissionAttempted === undefined
|
|
193
|
+
|| !checkpoint.requestBinding || !checkpoint.mediaAttempt && !checkpoint.mediaReference)) throw failure("REVIEW_NATIVE_CHECKPOINT_INVALID");
|
|
194
|
+
if (checkpoint.mediaReference && !checkpoint.apiScope) throw failure("REVIEW_NATIVE_SCOPE_INVALID");
|
|
195
|
+
if (checkpoint.apiScope && checkpoint.apiScope !== scope) throw failure("REVIEW_NATIVE_SCOPE_CHANGED");
|
|
196
|
+
if (checkpoint.jobId && checkpoint.submissionAttempted !== true || checkpoint.submissionAttempted === true && !checkpoint.mediaReference) throw failure("REVIEW_NATIVE_CHECKPOINT_INVALID");
|
|
197
|
+
let state = { ...checkpoint }, lastJob, uploadStatus, uploadInput, logical;
|
|
198
|
+
const timeoutMs = options.timeoutMs ?? 600000;
|
|
199
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 900000) throw new TypeError("Invalid native review deadline");
|
|
200
|
+
const deadline = now() + timeoutMs;
|
|
201
|
+
const check = () => {
|
|
202
|
+
if (getAuthority() !== authority) throw failure("REVIEW_NATIVE_OWNER_CHANGED");
|
|
203
|
+
if (signal?.aborted) throw failure("REVIEW_NATIVE_WAIT_ABORTED");
|
|
204
|
+
if (now() >= deadline) throw failure("REVIEW_NATIVE_WAIT_TIMEOUT");
|
|
205
|
+
};
|
|
206
|
+
async function bounded(callback, maximum = readTimeoutMs) {
|
|
207
|
+
check(); const controller = new AbortController(); let timer, listener;
|
|
208
|
+
const stop = new Promise((_, reject) => {
|
|
209
|
+
listener = () => { const error = failure("REVIEW_NATIVE_WAIT_ABORTED"); controller.abort(error); reject(error); };
|
|
210
|
+
signal?.addEventListener("abort", listener, { once: true });
|
|
211
|
+
timer = setTimer(() => { const error = failure("REVIEW_NATIVE_WAIT_TIMEOUT"); controller.abort(error); reject(error); }, Math.min(maximum, deadline - now()));
|
|
212
|
+
});
|
|
213
|
+
try { const value = await Promise.race([Promise.resolve().then(() => { check(); return callback(controller.signal); }), stop]); check(); return value; }
|
|
214
|
+
finally { clearTimer(timer); signal?.removeEventListener("abort", listener); }
|
|
215
|
+
}
|
|
216
|
+
const call = (path, init, maximum) => bounded(signal => serializedRequest(scope, authority, check,
|
|
217
|
+
() => { if (signal.aborted) throw signal.reason || failure("REVIEW_NATIVE_WAIT_ABORTED");
|
|
218
|
+
return request(path, { ...init, cache: "no-store", signal, timeoutMs: maximum || readTimeoutMs }); }), maximum);
|
|
219
|
+
// Durable writes are awaited to completion, never raced against timeout.
|
|
220
|
+
const persist = async patch => {
|
|
221
|
+
check(); state = captureReviewNativeCheckpoint({ ...state, ...patch });
|
|
222
|
+
if (typeof onCheckpoint !== "function") throw failure("REVIEW_NATIVE_JOURNAL_REQUIRED");
|
|
223
|
+
await onCheckpoint(state); check();
|
|
224
|
+
};
|
|
225
|
+
async function readUpload(value) {
|
|
226
|
+
const raw = capture(value, 16384), attempt = state.mediaAttempt;
|
|
227
|
+
if (!attempt || raw.attemptId !== attempt.attemptId || raw.automaticUploadRetryAllowed !== false
|
|
228
|
+
|| !["ready", "reserved", "unconfirmed", "not_found", "deleting", "deleted"].includes(raw.status)) throw failure("REVIEW_NATIVE_UPLOAD_RESPONSE_INVALID");
|
|
229
|
+
if (raw.status === "ready") {
|
|
230
|
+
if (raw.ok !== true) throw failure("REVIEW_NATIVE_UPLOAD_RESPONSE_INVALID");
|
|
231
|
+
const reference = mediaIdentity(raw.reference, true); await checkIdentity(reference); check();
|
|
232
|
+
if (reference.inputHash !== attempt.inputHash || reference.sha256 !== attempt.sha256 || reference.byteLength !== attempt.byteLength
|
|
233
|
+
|| raw.assetId !== reference.assetId || raw.namespace !== reference.namespace || raw.inputHash !== reference.inputHash
|
|
234
|
+
|| state.mediaReference && JSON.stringify(state.mediaReference) !== JSON.stringify(reference)) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
235
|
+
await persist({ mediaReference: reference });
|
|
236
|
+
} else if (raw.status !== "not_found" && (raw.inputHash !== attempt.inputHash || !ASSET.test(raw.assetId || "") || !HASH.test(raw.namespace || ""))) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
237
|
+
return raw;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
check();
|
|
241
|
+
if (state.mediaAttempt) { await checkIdentity(state.mediaAttempt); check(); }
|
|
242
|
+
if (state.mediaReference) { await checkIdentity(state.mediaReference); check(); }
|
|
243
|
+
if (state.mediaAttempt && state.mediaReference && state.mediaAttempt.inputHash !== state.mediaReference.inputHash) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
244
|
+
const suppliedMedia = stage === "analyzing" && (captured?.source || captured?.chunk) ? captured : stage === "verifying" ? captured?.chunk : null;
|
|
245
|
+
const existingIdentity = state.mediaReference || state.mediaAttempt;
|
|
246
|
+
if (suppliedMedia && existingIdentity) {
|
|
247
|
+
const media = uploadShape(suppliedMedia);
|
|
248
|
+
const hash = await digest(JSON.stringify({ version: 1, metadata: media.metadata, sha256: media.sha256, byteLength: media.bytes.length })); check();
|
|
249
|
+
if (hash !== existingIdentity.inputHash || await digest(media.bytes) !== existingIdentity.sha256) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH"); check();
|
|
250
|
+
}
|
|
251
|
+
if (state.submissionAttempted === true && !state.jobId) throw failure("REVIEW_NATIVE_ACCEPTANCE_UNKNOWN");
|
|
252
|
+
if (!recovering && options.readOnly !== true) {
|
|
253
|
+
const cap = await call(REVIEW_NATIVE_CAPABILITY, { method: "GET" });
|
|
254
|
+
if (cap?.ok !== true || cap.kind !== "review-native" || cap.version !== 2 || cap.automaticUploadRetryAllowed !== false || cap.paidReplayAllowed !== false || !["queue-v2", "legacy-sync", "unavailable"].includes(cap.mode)) throw failure("REVIEW_NATIVE_CAPABILITY_INVALID");
|
|
255
|
+
if (cap.mode === "legacy-sync") {
|
|
256
|
+
if (state.mediaReference) throw failure("REVIEW_NATIVE_CAPABILITY_CHANGED");
|
|
257
|
+
if (!captured) throw failure("REVIEW_NATIVE_INPUT_REQUIRED");
|
|
258
|
+
let legacy = captured;
|
|
259
|
+
if (stage === "verifying" && !captured.chunk) {
|
|
260
|
+
if (typeof options.getLegacyMedia !== "function") throw failure("REVIEW_NATIVE_INPUT_REQUIRED");
|
|
261
|
+
const media = capture(await bounded(() => options.getLegacyMedia()), 4 * Math.ceil(MAX_VIDEO / 3) + 16384);
|
|
262
|
+
legacy = { chunks: captured.chunks, story: captured.story, chunk: media };
|
|
263
|
+
}
|
|
264
|
+
return await call(`/api/tracker/review/${stages[stage].legacy}`, { method: "POST", body: JSON.stringify({ ...legacy, costEventId: requestId }) }, timeoutMs);
|
|
265
|
+
}
|
|
266
|
+
if (cap.mode === "unavailable") throw failure("REVIEW_NATIVE_UNAVAILABLE");
|
|
267
|
+
if (!Array.isArray(cap.jobTypes) || !cap.jobTypes.includes(stages[stage].type)) throw failure("REVIEW_NATIVE_UNAVAILABLE");
|
|
268
|
+
}
|
|
269
|
+
if (!state.mediaReference && !state.mediaAttempt) {
|
|
270
|
+
if (options.readOnly === true || !captured) throw failure("REVIEW_NATIVE_INPUT_REQUIRED");
|
|
271
|
+
uploadInput = uploadShape(stage === "analyzing" ? captured : captured.chunk);
|
|
272
|
+
if (await digest(uploadInput.bytes) !== uploadInput.sha256) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH"); check();
|
|
273
|
+
const base = { metadata: uploadInput.metadata, sha256: uploadInput.sha256, byteLength: uploadInput.bytes.length };
|
|
274
|
+
const mediaAttempt = mediaIdentity({ attemptId: uuid(), ...base, inputHash: await digest(JSON.stringify({ version: 1, ...base })) }); check();
|
|
275
|
+
logical = logicalInput(stage, captured, mediaAttempt);
|
|
276
|
+
const requestBinding = await digest(JSON.stringify({ requestId, stage, mediaInputHash: mediaAttempt.inputHash, ...logical })); check();
|
|
277
|
+
await persist({ transport: REVIEW_NATIVE_TRANSPORT, apiScope: scope, mediaAttempt, submissionAttempted: false, requestBinding });
|
|
278
|
+
uploadStatus = await readUpload(await call(REVIEW_NATIVE_ATTEMPTS, { method: "POST", body: JSON.stringify({ attemptId: mediaAttempt.attemptId, input: uploadInput.input, consent: true }) }));
|
|
279
|
+
}
|
|
280
|
+
if (!state.mediaReference && state.mediaAttempt) {
|
|
281
|
+
uploadStatus = await readUpload(await call(`${REVIEW_NATIVE_ATTEMPTS}/${state.mediaAttempt.attemptId}`, { method: "GET" }));
|
|
282
|
+
if (uploadStatus.status === "unconfirmed") uploadStatus = await readUpload(await call(`${REVIEW_NATIVE_ATTEMPTS}/${state.mediaAttempt.attemptId}/reconcile`, { method: "POST" }));
|
|
283
|
+
if (options.readOnly === true) return freeze({ kind: "review-media-attempt", ...uploadStatus });
|
|
284
|
+
if (!state.mediaReference) throw failure("REVIEW_NATIVE_UPLOAD_UNCONFIRMED");
|
|
285
|
+
}
|
|
286
|
+
if (!state.mediaReference) throw failure("REVIEW_NATIVE_MEDIA_REQUIRED");
|
|
287
|
+
logical ??= logicalInput(stage, captured, state.mediaReference);
|
|
288
|
+
if (logical) {
|
|
289
|
+
const binding = await digest(JSON.stringify({ requestId, stage, mediaInputHash: state.mediaReference.inputHash, ...logical })); check();
|
|
290
|
+
if (state.requestBinding && state.requestBinding !== binding) throw failure("REVIEW_NATIVE_INPUT_CHANGED");
|
|
291
|
+
if (!state.requestBinding) state.requestBinding = binding;
|
|
292
|
+
}
|
|
293
|
+
if (!state.jobId) {
|
|
294
|
+
if (options.readOnly === true) return freeze({ kind: "review-media-attempt", ...(uploadStatus || { status: "ready", reference: state.mediaReference, automaticUploadRetryAllowed: false }) });
|
|
295
|
+
if (!logical) throw failure("REVIEW_NATIVE_INPUT_REQUIRED");
|
|
296
|
+
if (state.mediaReference.expiresAt <= now()) throw failure("REVIEW_NATIVE_MEDIA_EXPIRED");
|
|
297
|
+
const ref = state.mediaReference, input = { assetId: ref.assetId, namespace: ref.namespace, mediaInputHash: ref.inputHash, ...logical };
|
|
298
|
+
if (encoder.encode(JSON.stringify(input)).length > (stage === "analyzing" ? 65536 : MAX_TEXT)) throw failure("REVIEW_NATIVE_INPUT_LIMIT");
|
|
299
|
+
await persist({ transport: REVIEW_NATIVE_TRANSPORT, apiScope: scope, submissionAttempted: true, requestBinding: state.requestBinding });
|
|
300
|
+
lastJob = jobFrom(await call(stages[stage].path, { method: "POST", body: JSON.stringify({ requestId, input, consent: true }) }), stage, requestId);
|
|
301
|
+
await persist({ jobId: lastJob.id });
|
|
302
|
+
}
|
|
303
|
+
for (;;) {
|
|
304
|
+
if (!lastJob) lastJob = jobFrom(await call(`${stages[stage].path}/${state.jobId}`, { method: "GET" }), stage, requestId, state.jobId);
|
|
305
|
+
if (options.readOnly === true) return lastJob;
|
|
306
|
+
if (lastJob.status === "completed") return unwrapReviewNativeJobResult(stage, lastJob.result, state.mediaReference);
|
|
307
|
+
if (!["queued", "pending", "running"].includes(lastJob.status)) throw failure("REVIEW_NATIVE_JOB_STOPPED", typeof lastJob.error === "string" && lastJob.error ? lastJob.error.slice(0, 1000) : undefined);
|
|
308
|
+
let timer; try { await bounded(() => new Promise(resolve => { timer = setTimer(resolve, pollMs); }), pollMs + readTimeoutMs); }
|
|
309
|
+
finally { if (timer !== undefined) clearTimer(timer); }
|
|
310
|
+
lastJob = null;
|
|
311
|
+
}
|
|
312
|
+
} catch (caught) {
|
|
313
|
+
if (getAuthority() !== authority) throw failure("REVIEW_NATIVE_OWNER_CHANGED");
|
|
314
|
+
let error = caught instanceof Error ? caught : failure("REVIEW_NATIVE_REQUEST_FAILED");
|
|
315
|
+
const known = knownFailures.get(error);
|
|
316
|
+
if (state.submissionAttempted === true && !state.jobId && known?.stage === stage && known.requestId === requestId) {
|
|
317
|
+
try { await persist({ jobId: known.jobId }); } catch (storageError) { error = storageError; }
|
|
318
|
+
if (getAuthority() !== authority) throw failure("REVIEW_NATIVE_OWNER_CHANGED");
|
|
319
|
+
}
|
|
320
|
+
if (!(error instanceof Error)) error = failure("REVIEW_NATIVE_REQUEST_FAILED");
|
|
321
|
+
// Raw error.jobId is not a recovery capability. Only an accepted response
|
|
322
|
+
// or adapter-branded, matching typed failure can populate this identity.
|
|
323
|
+
if (!state.jobId) delete error.jobId;
|
|
324
|
+
if (state.transport) Object.assign(error, state, { requestId, reconciliationRequired: true, paidReplayAllowed: false, automaticUploadRetryAllowed: false });
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return Object.freeze({ run, read: (stage, options = {}) => run(stage, null, { ...options, readOnly: true }) });
|
|
329
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Shared browser/MCP transport. No credentials, paid retries or server failover.
|
|
2
|
+
export const REVIEW_STORY_TRANSPORT = "review-story-queue-v1";
|
|
3
|
+
export const REVIEW_STORY_JOBS = "/api/tracker/review/story-jobs";
|
|
4
|
+
export const REVIEW_STORY_CAPABILITY = "/api/tracker/review/story-execution";
|
|
5
|
+
const UUID = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
|
|
6
|
+
const ID = new RegExp(`^${UUID}$`, "u"), JOB_ID = new RegExp(`^epoch-v2-${UUID}$`, "u");
|
|
7
|
+
const statuses = new Set(["queued", "pending", "running", "completed", "failed", "interrupted", "unknown", "cancelled", "cancel_requested"]);
|
|
8
|
+
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
const knownErrorJobs = new WeakMap();
|
|
10
|
+
// Only HTTP adapters call this with the typed server failure body. A copied
|
|
11
|
+
// public jobId/error property alone never becomes a trusted recovery handle.
|
|
12
|
+
export function attachReviewStoryJobFailureMetadata(error, payload) {
|
|
13
|
+
if (!error || typeof error !== "object" || !object(payload) || payload.submissionState !== "unknown"
|
|
14
|
+
|| payload.retryAllowed !== false || payload.paidReplayAllowed !== false || !ID.test(payload.requestId || "")
|
|
15
|
+
|| !JOB_ID.test(payload.jobId || "") || payload.statusPath !== `${REVIEW_STORY_JOBS}/${payload.jobId}`
|
|
16
|
+
|| payload.recoveryAction !== "read-known-job") return false;
|
|
17
|
+
const identity = { requestId: payload.requestId, jobId: payload.jobId, statusPath: payload.statusPath };
|
|
18
|
+
knownErrorJobs.set(error, identity);
|
|
19
|
+
Object.assign(error, identity, { paidReplayAllowed: false, retryAllowed: false, reconciliationRequired: true });
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
const failure = (code, message) => Object.assign(new Error(message), { code, paidReplayAllowed: false });
|
|
23
|
+
const validScope = value => {
|
|
24
|
+
let url; try { url = new URL(value); } catch { throw failure("REVIEW_STORY_SCOPE_INVALID", "분석 서버를 확인할 수 없습니다."); }
|
|
25
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw failure("REVIEW_STORY_SCOPE_INVALID", "분석 서버를 확인할 수 없습니다.");
|
|
26
|
+
return url.href.replace(/\/$/u, "");
|
|
27
|
+
};
|
|
28
|
+
function jobFrom(payload, requestId, expectedId) {
|
|
29
|
+
const job = payload?.job;
|
|
30
|
+
if (payload?.ok !== true || payload.async !== true || payload.requestId !== requestId || !object(job)
|
|
31
|
+
|| !JOB_ID.test(job.id || "") || (expectedId && job.id !== expectedId)
|
|
32
|
+
|| job.type !== "review:synthesize-story" || !statuses.has(job.status)) {
|
|
33
|
+
throw failure("REVIEW_STORY_RESPONSE_INVALID", "저장된 줄거리 작업을 안전하게 확인하지 못했습니다.");
|
|
34
|
+
}
|
|
35
|
+
if (job.result !== null && job.result !== undefined && new TextEncoder().encode(JSON.stringify(job.result)).length > 8 * 1024 * 1024) {
|
|
36
|
+
throw failure("REVIEW_STORY_RESULT_LIMIT", "저장된 줄거리 결과가 크기 한도를 초과했습니다.");
|
|
37
|
+
}
|
|
38
|
+
return job;
|
|
39
|
+
}
|
|
40
|
+
function captureInput(input) {
|
|
41
|
+
if (!object(input) || Object.keys(input).some(key => !["workTitle", "sources", "chunks"].includes(key))
|
|
42
|
+
|| !Array.isArray(input.sources) || !Array.isArray(input.chunks)) throw failure("REVIEW_STORY_INPUT_INVALID", "전체 줄거리 분석 입력을 확인해 주세요.");
|
|
43
|
+
const json = JSON.stringify(input);
|
|
44
|
+
if (new TextEncoder().encode(json).length > 4 * 1024 * 1024) throw failure("REVIEW_STORY_INPUT_LIMIT", "전체 분석 자료가 4MiB를 초과했습니다.");
|
|
45
|
+
return JSON.parse(json);
|
|
46
|
+
}
|
|
47
|
+
export function createReviewStoryJobClient({ request, getAuthority, apiScope, pollMs = 1000, readTimeoutMs = 30000,
|
|
48
|
+
now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
49
|
+
if (typeof request !== "function" || typeof getAuthority !== "function" || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 10000
|
|
50
|
+
|| !Number.isSafeInteger(readTimeoutMs) || readTimeoutMs < 1 || readTimeoutMs > 60000) throw new TypeError("Review story transport dependencies required");
|
|
51
|
+
const scope = validScope(apiScope);
|
|
52
|
+
async function run(input, options = {}) {
|
|
53
|
+
const { requestId, onSubmissionAttempt, onJobAccepted, signal } = options;
|
|
54
|
+
const authority = getAuthority();
|
|
55
|
+
let jobId = options.jobId || "", attempted = options.transport === REVIEW_STORY_TRANSPORT || Boolean(jobId), lastJob;
|
|
56
|
+
if (!authority) throw failure("REVIEW_STORY_AUTHORITY_REQUIRED", "로그인 후 줄거리 분석을 이용해 주세요.");
|
|
57
|
+
if (!ID.test(requestId || "") || (jobId && !JOB_ID.test(jobId))) throw failure("REVIEW_STORY_ID_INVALID", "저장된 줄거리 작업 ID를 확인해 주세요.");
|
|
58
|
+
if ((options.transport && options.transport !== REVIEW_STORY_TRANSPORT) || (attempted && !options.apiScope)) throw failure("REVIEW_STORY_SCOPE_INVALID", "기존 작업의 서버 기록이 없습니다. 다시 제출하지 않습니다.");
|
|
59
|
+
if (options.apiScope && validScope(options.apiScope) !== scope) throw failure("REVIEW_STORY_SCOPE_CHANGED", "원래 분석 서버에서 기존 작업을 확인해 주세요.");
|
|
60
|
+
const timeoutMs = options.timeoutMs ?? 600000;
|
|
61
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 900000) throw new TypeError("Invalid review waiting deadline");
|
|
62
|
+
const deadline = now() + timeoutMs;
|
|
63
|
+
const check = () => {
|
|
64
|
+
if (getAuthority() !== authority) throw failure("REVIEW_STORY_OWNER_CHANGED", "로그인 상태가 변경되어 이전 분석 조회를 중단했습니다.");
|
|
65
|
+
if (signal?.aborted) throw failure("REVIEW_STORY_WAIT_ABORTED", "분석 기다리기를 중단했습니다. 서버 작업을 취소하거나 재생성하지 않습니다.");
|
|
66
|
+
if (now() >= deadline) throw failure("REVIEW_STORY_WAIT_TIMEOUT", "분석 대기 시간이 초과되었습니다. 기존 작업만 조회하세요.");
|
|
67
|
+
};
|
|
68
|
+
async function bounded(callback, maximum = readTimeoutMs) {
|
|
69
|
+
check();
|
|
70
|
+
const controller = new AbortController(); let timer, listener;
|
|
71
|
+
const stop = new Promise((_, reject) => {
|
|
72
|
+
listener = () => { const error = failure("REVIEW_STORY_WAIT_ABORTED", "분석 기다리기를 중단했습니다."); controller.abort(error); reject(error); };
|
|
73
|
+
signal?.addEventListener("abort", listener, { once: true });
|
|
74
|
+
timer = setTimer(() => { const error = failure("REVIEW_STORY_WAIT_TIMEOUT", "분석 대기 시간이 초과되었습니다. 기존 작업만 조회하세요."); controller.abort(error); reject(error); }, Math.min(maximum, deadline - now()));
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
const value = await Promise.race([Promise.resolve().then(() => { check(); return callback(controller.signal); }), stop]);
|
|
78
|
+
check(); return value;
|
|
79
|
+
} finally { clearTimer(timer); signal?.removeEventListener("abort", listener); }
|
|
80
|
+
}
|
|
81
|
+
const call = (path, init, maximum) => bounded(signal => request(path, { ...init, cache: "no-store", signal, timeoutMs: maximum || readTimeoutMs }), maximum);
|
|
82
|
+
// Persistence is deliberately awaited, not raced against abort/deadline.
|
|
83
|
+
const persist = async (callback, value) => { check(); await callback(value); check(); };
|
|
84
|
+
try {
|
|
85
|
+
if (attempted && !jobId) throw failure("REVIEW_STORY_ACCEPTANCE_UNKNOWN", "접수 응답에서 작업 ID를 받지 못했습니다. 유료 단계를 다시 제출하지 않습니다.");
|
|
86
|
+
if (!jobId) {
|
|
87
|
+
const captured = captureInput(input);
|
|
88
|
+
const capability = await call(REVIEW_STORY_CAPABILITY, { method: "GET" });
|
|
89
|
+
if (capability?.version !== 1 || capability.kind !== "review-story" || capability.recovery !== "read-only-known-job"
|
|
90
|
+
|| capability.paidReplayAllowed !== false || !["legacy-sync", "queue-v2", "unavailable"].includes(capability.mode)) throw failure("REVIEW_STORY_CAPABILITY_INVALID", "줄거리 실행 방식이 확인되지 않아 생성을 시작하지 않았습니다.");
|
|
91
|
+
if (capability.mode === "unavailable") throw failure("REVIEW_STORY_UNAVAILABLE", "이 서버에서 줄거리 작업을 실행할 수 없습니다.");
|
|
92
|
+
if (capability.mode === "legacy-sync") return await call("/api/tracker/review/synthesize-story", { method: "POST", body: JSON.stringify(captured) }, timeoutMs);
|
|
93
|
+
if (typeof onSubmissionAttempt !== "function" || typeof onJobAccepted !== "function") throw failure("REVIEW_STORY_JOURNAL_REQUIRED", "작업 기록을 저장할 수 없어 분석을 시작하지 않았습니다.");
|
|
94
|
+
attempted = true;
|
|
95
|
+
await persist(onSubmissionAttempt, { transport: REVIEW_STORY_TRANSPORT, apiScope: scope });
|
|
96
|
+
lastJob = jobFrom(await call(REVIEW_STORY_JOBS, { method: "POST", body: JSON.stringify({ requestId, input: captured }) }), requestId);
|
|
97
|
+
jobId = lastJob.id;
|
|
98
|
+
await persist(onJobAccepted, { jobId, requestId, transport: REVIEW_STORY_TRANSPORT, apiScope: scope });
|
|
99
|
+
}
|
|
100
|
+
for (;;) {
|
|
101
|
+
if (!lastJob) lastJob = jobFrom(await call(`${REVIEW_STORY_JOBS}/${jobId}`, { method: "GET" }), requestId, jobId);
|
|
102
|
+
if (options.readOnly === true) return lastJob;
|
|
103
|
+
if (lastJob.status === "completed") {
|
|
104
|
+
if (!object(lastJob.result)) throw failure("REVIEW_STORY_RESULT_MISSING", "저장된 분석 결과를 확인하지 못했습니다.");
|
|
105
|
+
return lastJob.result;
|
|
106
|
+
}
|
|
107
|
+
if (!["queued", "pending", "running"].includes(lastJob.status)) throw failure("REVIEW_STORY_JOB_STOPPED", typeof lastJob.error === "string" ? lastJob.error.slice(0, 1000) : "기존 줄거리 작업을 확인해야 합니다. 다시 생성하지 않습니다.");
|
|
108
|
+
let timer;
|
|
109
|
+
try { await bounded(() => new Promise(resolve => { timer = setTimer(resolve, pollMs); }), pollMs + readTimeoutMs); }
|
|
110
|
+
finally { if (timer !== undefined) clearTimer(timer); }
|
|
111
|
+
lastJob = null;
|
|
112
|
+
}
|
|
113
|
+
} catch (caught) {
|
|
114
|
+
if (getAuthority() !== authority) throw failure("REVIEW_STORY_OWNER_CHANGED", "로그인 상태가 변경되어 이전 분석 조회를 중단했습니다.");
|
|
115
|
+
let error = caught instanceof Error ? caught : failure("REVIEW_STORY_REQUEST_FAILED", "분석 상태를 확인할 수 없습니다.");
|
|
116
|
+
const known = knownErrorJobs.get(error);
|
|
117
|
+
if (attempted && !jobId && known?.requestId === requestId) {
|
|
118
|
+
jobId = known.jobId;
|
|
119
|
+
try { await persist(onJobAccepted, { jobId, requestId, transport: REVIEW_STORY_TRANSPORT, apiScope: scope }); }
|
|
120
|
+
catch (storageError) { error = storageError; }
|
|
121
|
+
if (getAuthority() !== authority) throw failure("REVIEW_STORY_OWNER_CHANGED", "로그인 상태가 변경되어 이전 분석 조회를 중단했습니다.");
|
|
122
|
+
}
|
|
123
|
+
if (attempted) Object.assign(error, { requestId, jobId, transport: REVIEW_STORY_TRANSPORT, apiScope: scope, reconciliationRequired: true, paidReplayAllowed: false });
|
|
124
|
+
if (object(lastJob?.result)) for (const key of ["operationId", "accountCostEvents", "acceptedTaskCosts", "costUsd", "usage", "model", "partialProviderUsage"]) {
|
|
125
|
+
if (lastJob.result[key] !== undefined) error[key] = lastJob.result[key];
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return Object.freeze({ run, read: (jobId, options = {}) => run(null, { ...options, jobId, readOnly: true }) });
|
|
131
|
+
}
|
package/lib/review-tools.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { wavDurationSec } from "./audio-duration.mjs";
|
|
|
9
9
|
import { mp3DurationSec } from "./mp3-duration.mjs";
|
|
10
10
|
import { createUsageEventId } from "./usage-event.mjs";
|
|
11
11
|
import { assertReviewBudget, bindReviewRenderSources, inspectReviewLocalFile, readReviewHandle, REVIEW_MAX_FRAMES, reviewPublicSources, validateReviewSources } from "./review-sources.mjs";
|
|
12
|
-
import {
|
|
12
|
+
import { refreshFullReviewStatus, runFullReviewAnalysis, selectReviewPlanningCatalog } from "./review-full-analysis.mjs";
|
|
13
13
|
|
|
14
14
|
const spoiler = z.enum(["none", "limited", "full", "unknown"]);
|
|
15
15
|
const interval = z.object({ startMs: z.number().int().min(0), endMs: z.number().int().positive(),
|
|
@@ -96,10 +96,10 @@ export function registerReviewTools(server, config, api, { wrap = handler => han
|
|
|
96
96
|
}
|
|
97
97
|
});
|
|
98
98
|
server.tool("analyze_review_sources",
|
|
99
|
-
"영화·드라마 전체 영상+오디오를 Gemini 3.8로 분석하고 서사와 원본 근거를 재검증합니다. 기본 full은 모든 구간을 최대300초 MP4로 압축하여 클라우드에 전송하므로 사용자 업로드·과금 동의 후 allowCloudVideoUpload:true 필수. 첫 호출은 무료 원본 검사/범위 준비만, 이후 resumePath로 기본1단계씩 계속하세요. 완료된 청크는 재과금하지
|
|
99
|
+
"영화·드라마 전체 영상+오디오를 Gemini 3.8로 분석하고 서사와 원본 근거를 재검증합니다. 기본 full은 모든 구간을 최대300초 MP4로 압축하여 클라우드에 전송하므로 사용자 업로드·과금 동의 후 allowCloudVideoUpload:true 필수. 첫 호출은 무료 원본 검사/범위 준비만, 이후 resumePath로 기본1단계씩 계속하세요. 지원 서버에서는 영상 업로드·구간 분석·줄거리·원본 검증을 영속 작업으로 기록합니다. 기존 작업은 조회/업로드 확인만 하며 접수 미확정 유료 요청이나 업로드를 재전송하지 않습니다. 완료된 청크는 재과금하지 않습니다. 명시된 기존 동기 방식의 실패 재시도만 승인 후 가능합니다. 모순/근거 부족 판정은 저장하고 사람 검토를 기다립니다. 대표 프레임 점검만 원하면 analysisScope:'sample'을 명시하세요(전체 이해가 아님).",
|
|
100
100
|
{ sources: z.array(sourceInput).min(1).max(5).optional(), analysisScope: z.enum(["full", "sample"]).optional(),
|
|
101
101
|
allowCloudVideoUpload: z.boolean().optional(), resumePath: z.string().optional(), workTitle: z.string().max(200).optional(),
|
|
102
|
-
maxSteps: z.number().int().min(1).max(4).optional(), retryFailedStage: z.boolean().optional(),
|
|
102
|
+
maxSteps: z.number().int().min(1).max(4).optional(), retryFailedStage: z.boolean().optional().describe("기존 동기 실패 단계만 추가 과금 재시도 승인. 업로드/구간/줄거리/검증 큐의 접수 미확정 작업과 이미 받은 결과는 재전송하지 않습니다. 모순·근거 부족 판정도 재과금하지 않습니다."),
|
|
103
103
|
maxFrames: z.number().int().min(3).max(REVIEW_MAX_FRAMES).optional() },
|
|
104
104
|
wrapReview(async (args, extra) => {
|
|
105
105
|
if (args.analysisScope !== "sample") return result(await runFullReviewAnalysis({ config, api, args, companionJson,
|
|
@@ -194,7 +194,7 @@ export function registerReviewTools(server, config, api, { wrap = handler => han
|
|
|
194
194
|
}));
|
|
195
195
|
|
|
196
196
|
server.tool("review_analysis_status", "전체 원작 분석의 최신 진행률·청크 완료·원본 검증·누적 비용을 조회합니다. 유료 API를 호출하지 않습니다. 오래된 resumePath도 최신 체크포인트를 찾아 반환합니다.",
|
|
197
|
-
{ resumePath: z.string() }, wrapReview(async ({ resumePath }) => result(
|
|
197
|
+
{ resumePath: z.string() }, wrapReview(async ({ resumePath }) => result(await refreshFullReviewStatus(config, api, resumePath))));
|
|
198
198
|
|
|
199
199
|
server.tool("plan_review_video",
|
|
200
200
|
"검토한 영화·드라마 원본 카탈로그로 60~900초 리뷰를 기획합니다. 원작 사실·해석·가상해석을 구분하며 spoilerPolicy를 지킵니다. reviewSourcePath는 analyze_review_sources 결과. 영화 전체를 봤다고 가정하지 않으며 기획에 분석 과금이 발생할 수 있습니다. 반환 productionPath를 보이스 추천·리뷰 편집에 전달하세요.",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Exact browser capture of the server's durable TTS profile contract. This is
|
|
2
|
+
// creative intent, never receipt/state metadata or an arbitrary nested object.
|
|
3
|
+
export function captureSpeechProductionProfile(value) {
|
|
4
|
+
const fail = () => { throw Object.assign(new Error("음성 제작 프로필을 확인해 주세요."), { code: "SPEECH_INPUT_INVALID", paidReplayAllowed: false }); };
|
|
5
|
+
const fields = ["version", "categoryId", "tone", "deliveryStyle", "targetDurationSec", "imageSourceMode", "visualMotionMode", "autoComposeEnabled", "subtitleStylePresetId", "titleStylePresetId"];
|
|
6
|
+
if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
|
|
7
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
8
|
+
if (Reflect.ownKeys(descriptors).some(key => !fields.includes(key) || !Object.hasOwn(descriptors[key], "value"))) fail();
|
|
9
|
+
const copy = Object.fromEntries(Object.entries(descriptors).filter(([, item]) => item.value !== undefined).map(([key, item]) => [key, item.value]));
|
|
10
|
+
if (copy.version !== 1 || typeof copy.categoryId !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(copy.categoryId)
|
|
11
|
+
|| !["casual-social", "standard", "news-briefing"].includes(copy.deliveryStyle)) fail();
|
|
12
|
+
const maxSeconds = ["community-shorts", "viral-shorts"].includes(copy.categoryId) ? 180 : 3600;
|
|
13
|
+
if (typeof copy.targetDurationSec !== "number" || !Number.isFinite(copy.targetDurationSec) || copy.targetDurationSec < 0.01 || copy.targetDurationSec > maxSeconds) fail();
|
|
14
|
+
if (copy.tone !== undefined && (typeof copy.tone !== "string" || !copy.tone.isWellFormed() || copy.tone.length > 300 || /[\u0000-\u001f\u007f]/u.test(copy.tone))) fail();
|
|
15
|
+
if (copy.imageSourceMode !== undefined && !["ai", "web", "mixed"].includes(copy.imageSourceMode)) fail();
|
|
16
|
+
if (copy.visualMotionMode !== undefined && !["image-motion", "i2v"].includes(copy.visualMotionMode)) fail();
|
|
17
|
+
if (copy.autoComposeEnabled !== undefined && typeof copy.autoComposeEnabled !== "boolean") fail();
|
|
18
|
+
for (const key of ["subtitleStylePresetId", "titleStylePresetId"]) if (copy[key] !== undefined
|
|
19
|
+
&& (typeof copy[key] !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(copy[key]))) fail();
|
|
20
|
+
return Object.freeze(copy);
|
|
21
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Shared browser/server data contract. No provider, credentials, I/O or timing
|
|
2
|
+
// repair here: a malformed alignment is not permission to synthesize again.
|
|
3
|
+
// Two three-array alignments plus worst-case derived subtitle lines must fit
|
|
4
|
+
// the durable job's 65,536-node capture bound, including cost/receipt metadata.
|
|
5
|
+
export const TTS_ALIGNMENT_LIMITS = Object.freeze({ maxCharacters: 6000, maxDurationSeconds: 3600, maxJsonBytes: 1048576 });
|
|
6
|
+
const fail = () => { throw Object.assign(new Error("TTS_ALIGNMENT_INVALID"), { code: "TTS_ALIGNMENT_INVALID", statusCode: 502, paidReplayAllowed: false }); };
|
|
7
|
+
function record(value, names, required = names) {
|
|
8
|
+
if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
|
|
9
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
10
|
+
if (Reflect.ownKeys(fields).some(key => !names.includes(key) || !("value" in fields[key]))
|
|
11
|
+
|| required.some(key => !Object.hasOwn(fields, key))) fail();
|
|
12
|
+
return Object.fromEntries(Object.entries(fields).map(([key, item]) => [key, item.value]));
|
|
13
|
+
}
|
|
14
|
+
function array(value) {
|
|
15
|
+
if (!Array.isArray(value) || !value.length || value.length > TTS_ALIGNMENT_LIMITS.maxCharacters) fail();
|
|
16
|
+
const fields = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(fields);
|
|
17
|
+
if (keys.length !== value.length + 1 || keys.some(key => typeof key !== "string" || !("value" in fields[key])
|
|
18
|
+
|| key !== "length" && !/^(?:0|[1-9][0-9]*)$/u.test(key))) fail();
|
|
19
|
+
return Array.from({ length: value.length }, (_, index) => {
|
|
20
|
+
if (!Object.hasOwn(fields, index)) fail(); return fields[index].value;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function alignment(value) {
|
|
24
|
+
const source = record(value, ["characters", "character_start_times_seconds", "character_end_times_seconds"]);
|
|
25
|
+
const characters = array(source.characters), starts = array(source.character_start_times_seconds), ends = array(source.character_end_times_seconds);
|
|
26
|
+
if (characters.length !== starts.length || starts.length !== ends.length) fail();
|
|
27
|
+
for (let index = 0; index < characters.length; index++) {
|
|
28
|
+
const character = characters[index], start = starts[index], end = ends[index];
|
|
29
|
+
if (typeof character !== "string" || !character || !character.isWellFormed() || character.length > 16 || Array.from(character).length > 8
|
|
30
|
+
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(character)
|
|
31
|
+
|| typeof start !== "number" || typeof end !== "number" || !Number.isFinite(start) || !Number.isFinite(end)
|
|
32
|
+
|| start < 0 || end < start || end > TTS_ALIGNMENT_LIMITS.maxDurationSeconds
|
|
33
|
+
|| index > 0 && (start < starts[index - 1] || end < ends[index - 1])) fail();
|
|
34
|
+
}
|
|
35
|
+
return Object.freeze({ characters: Object.freeze(characters), character_start_times_seconds: Object.freeze(starts), character_end_times_seconds: Object.freeze(ends) });
|
|
36
|
+
}
|
|
37
|
+
export function captureTtsAlignment(value, { requireAlignment = true } = {}) {
|
|
38
|
+
if (typeof requireAlignment !== "boolean") fail();
|
|
39
|
+
const source = record(value, ["alignment", "normalizedAlignment"], []);
|
|
40
|
+
const primary = source.alignment == null ? null : alignment(source.alignment);
|
|
41
|
+
if (!primary && requireAlignment) fail();
|
|
42
|
+
const result = Object.freeze({ alignment: primary, normalizedAlignment: source.normalizedAlignment == null ? null : alignment(source.normalizedAlignment) });
|
|
43
|
+
if (new TextEncoder().encode(JSON.stringify(result)).byteLength > TTS_ALIGNMENT_LIMITS.maxJsonBytes) fail();
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Preserve the existing ElevenLabs subtitle splitting contract. This function
|
|
48
|
+
// consumes a previously validated primary alignment; it never estimates time.
|
|
49
|
+
export function buildSpeechSubtitleLines({ alignment: source, minChars = 6 } = {}) {
|
|
50
|
+
const value = alignment(source);
|
|
51
|
+
if (!Number.isSafeInteger(minChars) || minChars < 1 || minChars > 1000) fail();
|
|
52
|
+
const chars = value.characters, starts = value.character_start_times_seconds, ends = value.character_end_times_seconds;
|
|
53
|
+
const breakers = new Set([".", "?", "!", "\n", "\r"]), softBreakers = new Set([",", ";", "·"]);
|
|
54
|
+
const lines = []; let buffer = "", lineStart = -1;
|
|
55
|
+
function pushLine(endIndex) {
|
|
56
|
+
const trimmed = buffer.trim(); if (!trimmed) return;
|
|
57
|
+
const startSec = starts[Math.max(0, lineStart)], endSec = ends[endIndex];
|
|
58
|
+
if (lines.length && trimmed.length < minChars) {
|
|
59
|
+
const previous = lines[lines.length - 1]; previous.text = `${previous.text} ${trimmed}`.trim(); previous.endSec = Math.max(previous.endSec, endSec);
|
|
60
|
+
} else lines.push({ text: trimmed, startSec, endSec });
|
|
61
|
+
buffer = ""; lineStart = -1;
|
|
62
|
+
}
|
|
63
|
+
for (let index = 0; index < chars.length; index++) {
|
|
64
|
+
const character = chars[index]; if (lineStart === -1 && character.trim()) lineStart = index; buffer += character;
|
|
65
|
+
if (breakers.has(character) || softBreakers.has(character) && buffer.replace(/[,\s;·]+$/, "").length >= minChars * 2) pushLine(index);
|
|
66
|
+
}
|
|
67
|
+
if (buffer.trim()) pushLine(chars.length - 1);
|
|
68
|
+
return Object.freeze(lines.map(Object.freeze));
|
|
69
|
+
}
|