aimakeall-mcp 0.14.2 → 0.14.3

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 CHANGED
@@ -5,6 +5,14 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
5
5
  - **클라우드 툴**: 기획 → 씬 이미지/영상 생성 → TTS/음악 → 타임라인 스티치 → YouTube 퍼블리시 (PAT 인증, aimakeall.com API)
6
6
  - **로컬 툴**: mp4 렌더 — **항상 이 PC의 AImakeAll 컴패니언(127.0.0.1:9876)에서 수행**됩니다. 서버는 렌더하지 않습니다.
7
7
 
8
+ ## 0.14.3 리뷰 분석 업데이트
9
+
10
+ 전편 분석의 서버 실행 방식 조회, 계정·서버별 체크포인트 고정, 알려진 작업의 읽기 전용
11
+ 복구를 추가했습니다. 공유 큐 실행은 서버가 명시적으로 제공할 때만 사용합니다.
12
+ 기존 단일 서버에서는 명시된 동기 실행 방식을 유지하며, 요청 실패 후 유료 작업을
13
+ 자동으로 다시 생성하지 않습니다. 이는 운영 PostgreSQL·자동 확장 활성화를 의미하지 않습니다.
14
+ 실행 중인 제작은 먼저 완료한 뒤 MCP를 재연결하세요. 설치용 부트스트랩 버전은 그대로입니다.
15
+
8
16
  ## 설정
9
17
 
10
18
  macOS·Windows에서 **Node.js 20 이상**이 필요합니다.
@@ -1,9 +1,20 @@
1
1
  import { MCP_PROXY_VERSION } from "./config.mjs";
2
+ import { attachReviewStoryJobFailureMetadata, REVIEW_STORY_JOBS } from "./review-story-job-request.mjs";
3
+ import { attachReviewNativeJobFailureMetadata } from "./review-native-job-request.mjs";
2
4
 
3
5
  // aimakeall.com API 클라이언트 — PAT(Bearer) 인증 + 프록시 버전 핸드셰이크.
4
6
  // 서버 오류 코드를 사용자 행동 가능한 한국어 안내로 매핑한다.
5
7
 
6
8
  const DEFAULT_TIMEOUT_MS = 60_000;
9
+ const clientAuthorities = new WeakMap();
10
+ // Authenticated transports must not silently use the token captured by an old
11
+ // API client after its caller's config changed. Non-HTTP test adapters return
12
+ // null; the actual client identity remains private and exposes no credential.
13
+ export function matchesApiClientAuthority(client, { apiBase, pat } = {}) {
14
+ const binding = clientAuthorities.get(client);
15
+ return binding ? typeof pat === "string" && pat.length > 0 && binding.pat === pat
16
+ && binding.apiBase === String(apiBase || "").replace(/\/$/u, "") : null;
17
+ }
7
18
 
8
19
  export class AimakeallApiError extends Error {
9
20
  constructor(message, { errorCode = "", retryAfterSeconds = 0, status = 0, accountCostEvents, acceptedTaskCosts, operationId, costUsd, usage, partialProviderUsage, fallbackMetadata } = {}) {
@@ -42,7 +53,7 @@ function describeApiFailure(status, payload) {
42
53
  }
43
54
 
44
55
  export function createApiClient({ apiBase, pat }) {
45
- async function request(pathName, { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS, rawBody, headers = {} } = {}) {
56
+ async function request(pathName, { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS, rawBody, headers = {}, signal, cache } = {}) {
46
57
  const requestHeaders = {
47
58
  Authorization: `Bearer ${pat}`,
48
59
  "X-AImakeAll-MCP-Version": MCP_PROXY_VERSION,
@@ -62,7 +73,8 @@ export function createApiClient({ apiBase, pat }) {
62
73
  ...(rawBody !== undefined && typeof rawBody?.pipe !== "function" && rawBody?.[Symbol.asyncIterator] ? { duplex: "half" } : {}),
63
74
  headers: requestHeaders,
64
75
  method,
65
- signal: AbortSignal.timeout(timeoutMs),
76
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
77
+ ...(cache === undefined ? {} : { cache }),
66
78
  });
67
79
 
68
80
  const text = await response.text();
@@ -74,7 +86,7 @@ export function createApiClient({ apiBase, pat }) {
74
86
  }
75
87
 
76
88
  if (!response.ok) {
77
- throw new AimakeallApiError(describeApiFailure(response.status, payload), {
89
+ const error = new AimakeallApiError(describeApiFailure(response.status, payload), {
78
90
  errorCode: String(payload?.code || payload?.error || ""),
79
91
  retryAfterSeconds: Number(payload?.retryAfterSeconds) || Number(response.headers.get("retry-after")) || 0,
80
92
  status: response.status,
@@ -83,6 +95,9 @@ export function createApiClient({ apiBase, pat }) {
83
95
  usage: payload?.usage, partialProviderUsage: payload?.partialProviderUsage,
84
96
  fallbackMetadata: payload,
85
97
  });
98
+ if (pathName === REVIEW_STORY_JOBS) attachReviewStoryJobFailureMetadata(error, payload);
99
+ if (["/api/tracker/review/chunk-jobs", "/api/tracker/review/evidence-jobs"].includes(pathName)) attachReviewNativeJobFailureMetadata(error, payload);
100
+ throw error;
86
101
  }
87
102
  return payload;
88
103
  }
@@ -229,5 +244,7 @@ export function createApiClient({ apiBase, pat }) {
229
244
  return { events, result };
230
245
  }
231
246
 
232
- return { request, requestBinary, requestSse, requestStreamUpload };
247
+ const client = { request, requestBinary, requestSse, requestStreamUpload };
248
+ clientAuthorities.set(client, { apiBase: String(apiBase || "").replace(/\/$/u, ""), pat });
249
+ return client;
233
250
  }
package/lib/config.mjs CHANGED
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import path from "node:path";
3
3
 
4
4
  // 프록시 버전 — 서버가 X-AImakeAll-MCP-Version 으로 하한을 강제(426)할 수 있다.
5
- export const MCP_PROXY_VERSION = "0.14.2";
5
+ export const MCP_PROXY_VERSION = "0.14.3";
6
6
 
7
7
  export const DEFAULT_API_BASE = "https://aimakeall.com";
8
8
  export const DEFAULT_COMPANION_URL = "http://127.0.0.1:9876";
@@ -4,6 +4,10 @@ import path from "node:path";
4
4
  import { savePayloadHandle } from "./media-store.mjs";
5
5
  import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
6
6
  import { inspectReviewLocalFile, readReviewHandle, reviewPublicSources, validateReviewSources } from "./review-sources.mjs";
7
+ import { createReviewStoryJobClient, REVIEW_STORY_TRANSPORT } from "./review-story-job-request.mjs";
8
+ import { createReviewNativeJobClient, REVIEW_NATIVE_TRANSPORT, unwrapReviewNativeJobResult } from "./review-native-job-request.mjs";
9
+ import { DEFAULT_API_BASE } from "./config.mjs";
10
+ import { matchesApiClientAuthority } from "./api-client.mjs";
7
11
 
8
12
  const KIND = "aimakeall-review-full-analysis";
9
13
  const MAX_CHUNK_BYTES = 10 * 1024 * 1024;
@@ -68,14 +72,64 @@ function lockAnalysis(config, analysisId) {
68
72
  function receipt(value, stage, chunkId = "") {
69
73
  return { stage, chunkId, ...Object.fromEntries(RECEIPT_KEYS.filter(key => value?.[key] !== undefined).map(key => [key, value[key]])) };
70
74
  }
75
+ function upsertStoryReceipt(state, value, operationId) {
76
+ upsertStageReceipt(state, value, operationId, "synthesizing");
77
+ }
78
+ function upsertStageReceipt(state, value, operationId, stage, chunkId = "") {
79
+ const item = { ...receipt(value, stage, chunkId), clientOperationId: operationId };
80
+ const index = state.receipts.findIndex(row => row.clientOperationId === operationId);
81
+ if (index < 0) state.receipts.push(item);
82
+ else {
83
+ const previous = state.receipts[index];
84
+ const unique = field => [...new Map([...(previous[field] || []), ...(item[field] || [])]
85
+ .map(event => [event.accountCostEventId || event.eventId || event.id || JSON.stringify(event), event])).values()];
86
+ state.receipts[index] = { ...previous, ...item, usage: item.usage || previous.usage,
87
+ operationId: item.operationId || previous.operationId, costUsd: Math.max(Number(previous.costUsd) || 0, Number(item.costUsd) || 0),
88
+ accountCostEvents: unique("accountCostEvents"), acceptedTaskCosts: unique("acceptedTaskCosts") };
89
+ }
90
+ }
91
+ function authorityBinding(config) {
92
+ const scope = new URL(config.apiBase || DEFAULT_API_BASE).href.replace(/\/$/u, "");
93
+ return { apiScope: scope, authorityFingerprint: createHash("sha256").update(JSON.stringify([scope, config.pat || ""])).digest("hex") };
94
+ }
95
+ function assertCheckpointAuthority(config, state) {
96
+ const current = authorityBinding(config);
97
+ if (!state.authorityBinding && (state.inFlight || state.pendingStageResult || state.receipts?.length || Object.keys(state.analyses || {}).length)) {
98
+ throw fail("REVIEW_CHECKPOINT_AUTHORITY_MISSING", "이전 분석 기록에 서버·계정 연결 증명이 없습니다. 기존 파일의 로컬 확인만 가능하며 현재 PAT로 결과를 적용하거나 유료 요청을 다시 보내지 않습니다.");
99
+ }
100
+ if (state.authorityBinding && (state.authorityBinding.apiScope !== current.apiScope || state.authorityBinding.authorityFingerprint !== current.authorityFingerprint)) {
101
+ throw fail("REVIEW_CHECKPOINT_AUTHORITY_CHANGED", "분석을 시작한 서버와 PAT로 기존 작업을 확인해 주세요. 계정이 변경된 상태에서는 업로드·유료 요청·결과 조회를 하지 않습니다.");
102
+ }
103
+ state.authorityBinding ||= current;
104
+ }
105
+ function storyClient(config, api) {
106
+ return createReviewStoryJobClient({ apiScope: config.apiBase || DEFAULT_API_BASE,
107
+ getAuthority: () => transportAuthority(config, api),
108
+ request: (route, options) => api.request(route, { ...options, ...(options.body === undefined ? {} : { body: JSON.parse(options.body) }) }),
109
+ });
110
+ }
111
+ function nativeClient(config, api) {
112
+ return createReviewNativeJobClient({ apiScope: config.apiBase || DEFAULT_API_BASE,
113
+ getAuthority: () => transportAuthority(config, api),
114
+ request: (route, options) => api.request(route, { ...options, ...(options.body === undefined ? {} : { body: JSON.parse(options.body) }) }),
115
+ });
116
+ }
117
+ function transportAuthority(config, api) {
118
+ if (matchesApiClientAuthority(api, { apiBase: config.apiBase || DEFAULT_API_BASE, pat: config.pat }) === false) return null;
119
+ return config.pat ? JSON.stringify([config.apiBase || DEFAULT_API_BASE, config.pat]) : api;
120
+ }
121
+ function mediaDescriptor(media) {
122
+ const { videoBase64, ...chunk } = media.chunk;
123
+ return { source: media.source, chunk, ...(media.workTitle !== undefined ? { workTitle: media.workTitle } : {}) };
124
+ }
71
125
  function safeStageResult(value) {
72
126
  if (Array.isArray(value)) return value.map(safeStageResult);
73
127
  if (!value || typeof value !== "object") return value;
74
- return Object.fromEntries(Object.entries(value).filter(([key]) => !/base64|dataUrl|localPath|sourceHandle|apiKey|token/i.test(key)).map(([key, item]) => [key, safeStageResult(item)]));
128
+ return Object.fromEntries(Object.entries(value).filter(([key]) => !/base64|dataUrl|localPath|sourceHandle|apiKey|^token$|accessToken|refreshToken|authorization|cookie/i.test(key)).map(([key, item]) => [key, safeStageResult(item)]));
75
129
  }
76
130
  function billing(state) {
77
131
  const costs = state.receipts || [];
78
- const unique = (field) => [...new Map(costs.flatMap(item => item[field] || []).map(item => [item.eventId || item.id || JSON.stringify(item), item])).values()];
132
+ const unique = (field) => [...new Map(costs.flatMap(item => item[field] || []).map(item => [item.accountCostEventId || item.eventId || item.id || JSON.stringify(item), item])).values()];
79
133
  return { accountCostEvents: unique("accountCostEvents"), acceptedTaskCosts: unique("acceptedTaskCosts"),
80
134
  costUsd: costs.reduce((sum, item) => sum + (Number(item.costUsd) || 0), 0), stageReceipts: costs };
81
135
  }
@@ -94,12 +148,58 @@ function summary(state, resumePath) {
94
148
  analyzedTimelineSec: analyzedMs / 1000, totalTimelineSec: state.sources.reduce((sum, item) => sum + item.durationSec, 0) },
95
149
  sources: reviewPublicSources(state.sources), ...billing(state),
96
150
  warnings: state.warnings || [], error: state.error || undefined,
151
+ ...(state.inFlight?.transport === REVIEW_STORY_TRANSPORT ? { storyJob: { requestId: state.inFlight.operationId,
152
+ jobId: state.inFlight.jobId || null, apiScope: state.inFlight.apiScope, recovery: "read-only-known-job", paidReplayAllowed: false } } : {}),
153
+ ...(state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT ? { nativeJob: { stage: state.inFlight.stage,
154
+ requestId: state.inFlight.operationId, jobId: state.inFlight.jobId || null, status: state.inFlight.jobStatus || "unknown",
155
+ apiScope: state.inFlight.apiScope, mediaAttemptId: state.inFlight.mediaAttempt?.attemptId || null,
156
+ fileState: state.inFlight.mediaReference ? "ready" : state.inFlight.mediaAttempt ? "reconciliation-required" : "not-uploaded",
157
+ recovery: state.inFlight.jobId ? "read-only-known-job" : state.inFlight.submissionAttempted ? "submission-unknown-no-replay" : "reconcile-upload",
158
+ paidReplayAllowed: false, automaticUploadRetryAllowed: false } } : {}),
159
+ mediaFiles: { ready: Object.keys(state.mediaReferences || {}).length, retainedForVerification: true },
160
+ reviewRequired: state.reviewRequired === true,
97
161
  failedStageResultPath: state.failedStageResultPath || undefined,
98
162
  requiresHumanReview: true, next: state.stage === "complete" ? "plan_review_video({reviewSourcePath,...})" : state.stage === "blocked" ? "실패 원인·이미 접수된 비용을 확인하세요. 자동 재시도하지 않습니다." : "analyze_review_sources({resumePath,allowCloudVideoUpload:true})로 다음 1개 단계를 계속하세요.",
99
163
  };
100
164
  }
101
165
  export function fullReviewStatus(config, resumePath) { const loaded = loadFullReviewAnalysis(config, resumePath); return summary(loaded.state, loaded.resumePath); }
102
166
 
167
+ export async function refreshFullReviewStatus(config, api, resumePath) {
168
+ const loaded = loadFullReviewAnalysis(config, resumePath);
169
+ const release = lockAnalysis(config, loaded.state.analysisId);
170
+ try {
171
+ let { state, resumePath: latestPath } = loadFullReviewAnalysis(config, resumePath);
172
+ assertCheckpointAuthority(config, state);
173
+ const operation = state.inFlight;
174
+ if (![REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(operation?.transport)
175
+ || operation.transport === REVIEW_STORY_TRANSPORT && !operation.jobId) return summary(state, latestPath);
176
+ try {
177
+ const job = operation.transport === REVIEW_STORY_TRANSPORT
178
+ ? await storyClient(config, api).read(operation.jobId, { requestId: operation.operationId, apiScope: operation.apiScope })
179
+ : await nativeClient(config, api).read(operation.stage, { ...operation, requestId: operation.operationId,
180
+ onCheckpoint: async metadata => {
181
+ assertCheckpointAuthority(config, state); Object.assign(operation, metadata);
182
+ if (operation.mediaReference) (state.mediaReferences ||= {})[operation.chunkId] = operation.mediaReference;
183
+ latestPath = snapshot(config, state);
184
+ } });
185
+ assertCheckpointAuthority(config, state);
186
+ operation.jobStatus = job.status;
187
+ if (job.status === "completed") {
188
+ if (!job.result || typeof job.result !== "object" || Array.isArray(job.result)) throw fail("REVIEW_STORY_RESULT_MISSING", "저장된 줄거리 결과가 없습니다.");
189
+ const result = operation.transport === REVIEW_STORY_TRANSPORT ? job.result
190
+ : unwrapReviewNativeJobResult(operation.stage, job.result, operation.mediaReference);
191
+ upsertStageReceipt(state, result, operation.operationId, operation.stage, operation.chunkId);
192
+ state.pendingStageResult = safeStageResult(result);
193
+ }
194
+ latestPath = snapshot(config, state);
195
+ } catch (error) {
196
+ state.error = { code: error.code || error.errorCode || "REVIEW_JOB_READ_FAILED", message: String(error.message || error) };
197
+ latestPath = snapshot(config, state);
198
+ }
199
+ return summary(state, latestPath);
200
+ } finally { release(); }
201
+ }
202
+
103
203
  // This limits only the editing candidate list, never the full story/transcript
104
204
  // evidence. All native analysis remains in the original immutable handle.
105
205
  export function selectReviewPlanningCatalog(handle, requestedIds) {
@@ -188,7 +288,8 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
188
288
  const health = await companionJson(config, "/health", undefined, 15000);
189
289
  if (health.capabilities?.reviewSourceAnalysis !== true || health.capabilities?.reviewSourceChunks !== true || health.capabilities.reviewSourceChunkMaxDurationSec < 300 || health.capabilities.reviewSourceChunkMaxBytes < MAX_CHUNK_BYTES) throw fail("COMPANION_UPDATE_REQUIRED", "전체 영상·음성 분석 청크를 지원하는 로컬 처리기로 업데이트하세요. 대표 프레임으로 우회하지 않습니다.");
190
290
  const state = { kind: KIND, version: 1, analysisId: randomUUID(), revision: 0, stage: "analyzing", workTitle: args.workTitle || "",
191
- sources: [], chunks: [], analyses: {}, verifications: {}, receipts: [], warnings: ["전체 구간을 압축 영상+오디오로 분석하고 서사 주장을 실제 원본 구간으로 재검증합니다. 공개 권리와 최종 해석은 사람이 확인해야 합니다."], uploadConsent: true };
291
+ sources: [], chunks: [], analyses: {}, verifications: {}, receipts: [], mediaReferences: {}, authorityBinding: authorityBinding(config),
292
+ warnings: ["전체 구간을 압축 영상+오디오로 분석하고 서사 주장을 실제 원본 구간으로 재검증합니다. 공개 권리와 최종 해석은 사람이 확인해야 합니다."], uploadConsent: true };
192
293
  const fingerprints = args.sources.map(source => source.kind === "local" ? inspectReviewLocalFile(source.videoPath) : null);
193
294
  for (const [index, input] of args.sources.entries()) {
194
295
  const id = `review-${randomUUID().slice(0, 8)}`;
@@ -209,16 +310,27 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
209
310
  let state, resumePath;
210
311
  try {
211
312
  ({ state, resumePath } = loadFullReviewAnalysis(config, args.resumePath));
212
- validateReviewSources(state.sources);
313
+ assertCheckpointAuthority(config, state);
213
314
  if (state.stage === "complete") return summary(state, resumePath);
315
+ if (state.reviewRequired) return { ...summary(state, resumePath), requiresExplicitRetryApproval: false };
214
316
  if (state.inFlight || state.stage === "blocked") {
317
+ if ([REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(state.inFlight?.transport) || state.pendingStageResult) {
318
+ if (!state.inFlight || !["analyzing", "synthesizing", "verifying"].includes(state.inFlight.stage)
319
+ || state.inFlight.transport === REVIEW_STORY_TRANSPORT && state.inFlight.stage !== "synthesizing") throw fail("REVIEW_JOB_STATE_INVALID", "저장된 작업 단계가 다릅니다.");
320
+ state.stage = state.inFlight.stage;
321
+ delete state.error;
322
+ } else {
215
323
  if (args.retryFailedStage !== true) return { ...summary(state, resumePath), requiresExplicitRetryApproval: true };
216
324
  state.stage = state.failedStage || state.inFlight?.stage || "analyzing";
217
325
  delete state.inFlight; delete state.error;
326
+ }
218
327
  resumePath = snapshot(config, state);
219
328
  }
220
329
  const refreshed = new Set();
221
330
  const getMedia = async (chunk, expectedSha256) => {
331
+ // A known server job / saved result does not need the local source. Only
332
+ // a genuinely new media transfer or explicit legacy recheck reads it.
333
+ validateReviewSources(state.sources);
222
334
  const index = state.sources.findIndex(source => source.id === chunk.sourceId);
223
335
  try { return await videoChunk(config, companionJson, state.sources[index], chunk, expectedSha256); }
224
336
  catch (error) {
@@ -232,41 +344,87 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
232
344
  if (!onProgress) return;
233
345
  try { await onProgress(summary(state, resumePath)); } catch { /* Progress delivery cannot replay/abort an accepted paid operation. */ }
234
346
  };
235
- const charge = async (stage, chunkId, route, body) => {
236
- state.inFlight = { stage, chunkId, operationId: randomUUID() }; state.failedStage = stage;
347
+ const charge = async (stage, chunkId, body, options = {}) => {
348
+ assertCheckpointAuthority(config, state);
349
+ const existing = state.inFlight;
350
+ if (existing && (existing.stage !== stage || existing.chunkId !== chunkId)) throw fail("REVIEW_JOB_STATE_INVALID", "체크포인트의 작업 구간이 다릅니다.");
351
+ if (state.pendingStageResult) return state.pendingStageResult;
352
+ state.inFlight = existing || { stage, chunkId, operationId: randomUUID(),
353
+ ...(stage === "analyzing" ? { mediaDescriptor: mediaDescriptor(body), priorContext: body.priorContext || "" } : {}) }; state.failedStage = stage;
237
354
  resumePath = snapshot(config, state);
238
355
  await progress();
239
356
  let response;
240
- try { response = await api.request(route, { method: "POST", body: { ...body, costEventId: state.inFlight.operationId }, timeoutMs: 600000 }); }
241
- catch (error) { state.receipts.push(receipt(error, stage, chunkId)); throw error; }
242
- state.receipts.push(receipt(response, stage, chunkId));
357
+ const operationId = state.inFlight.operationId;
358
+ try {
359
+ response = stage === "synthesizing"
360
+ ? await storyClient(config, api).run(body, {
361
+ requestId: operationId, ...(existing ? { transport: existing.transport, jobId: existing.jobId, apiScope: existing.apiScope } : {}),
362
+ onSubmissionAttempt: async metadata => { Object.assign(state.inFlight, metadata); resumePath = snapshot(config, state); },
363
+ onJobAccepted: async metadata => { Object.assign(state.inFlight, metadata); resumePath = snapshot(config, state); },
364
+ })
365
+ : await nativeClient(config, api).run(stage, body, { ...state.inFlight, requestId: operationId,
366
+ // A resumed operation owns its exact reference and origin. Never
367
+ // repair a damaged/missing binding from the analysis-wide cache.
368
+ ...(!existing && options.mediaReference ? { mediaReference: options.mediaReference,
369
+ apiScope: state.authorityBinding.apiScope } : {}),
370
+ getLegacyMedia: options.getLegacyMedia,
371
+ onCheckpoint: async metadata => {
372
+ assertCheckpointAuthority(config, state); Object.assign(state.inFlight, metadata);
373
+ if (state.inFlight.mediaReference) (state.mediaReferences ||= {})[chunkId] = state.inFlight.mediaReference;
374
+ resumePath = snapshot(config, state);
375
+ },
376
+ });
377
+ }
378
+ catch (error) {
379
+ if (stage === "synthesizing") upsertStoryReceipt(state, error, operationId);
380
+ else upsertStageReceipt(state, error, operationId, stage, chunkId);
381
+ throw error;
382
+ }
383
+ assertCheckpointAuthority(config, state);
384
+ if (stage === "synthesizing") upsertStoryReceipt(state, response, operationId);
385
+ else upsertStageReceipt(state, response, operationId, stage, chunkId);
386
+ if (state.inFlight.transport) state.inFlight.jobStatus = "completed";
243
387
  state.pendingStageResult = safeStageResult(response);
388
+ // Commit received output before validation/application can fail or MCP exits.
389
+ resumePath = snapshot(config, state);
244
390
  return response;
245
391
  };
246
392
  for (let step = 0; step < (args.maxSteps ?? 1); step++) {
247
393
  if (state.stage === "analyzing") {
248
394
  const chunk = state.chunks.find(item => !state.analyses[item.chunkId]);
249
395
  if (!chunk) { state.stage = "synthesizing"; resumePath = snapshot(config, state); step--; continue; }
250
- const media = await getMedia(chunk);
251
- media.workTitle = state.workTitle;
252
- const previous = Object.values(state.analyses).filter(item => item.sourceId === chunk.sourceId && item.startMs < chunk.startMs).sort((a, b) => b.startMs - a.startMs)[0];
253
- if (previous) media.priorContext = JSON.stringify({ previousSummary: previous.summary, observedCharacters: previous.characters }).slice(0, 12000);
254
- const response = await charge("analyzing", chunk.chunkId, "/api/tracker/review/analyze-chunk", media);
396
+ let media;
397
+ if (state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT || state.pendingStageResult) {
398
+ media = state.inFlight.mediaDescriptor;
399
+ if (!media?.chunk || media.chunk.chunkId !== chunk.chunkId) throw fail("REVIEW_MEDIA_CHECKPOINT_INVALID", "저장된 미디어 구간 정보가 없습니다. 다시 인코딩하거나 업로드하지 않습니다.");
400
+ } else {
401
+ media = await getMedia(chunk); media.workTitle = state.workTitle;
402
+ const previous = Object.values(state.analyses).filter(item => item.sourceId === chunk.sourceId && item.startMs < chunk.startMs).sort((a, b) => b.startMs - a.startMs)[0];
403
+ if (previous) media.priorContext = JSON.stringify({ previousSummary: previous.summary, observedCharacters: previous.characters }).slice(0, 12000);
404
+ }
405
+ const response = await charge("analyzing", chunk.chunkId, state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT
406
+ ? state.inFlight.jobId ? null : { priorContext: state.inFlight.priorContext || "" } : media);
255
407
  state.analyses[chunk.chunkId] = checkedAnalysis(response, chunk, media);
256
408
  } else if (state.stage === "synthesizing") {
257
409
  const body = { sources: reviewPublicSources(state.sources), chunks: Object.values(state.analyses), workTitle: state.workTitle };
258
410
  textBudget(body);
259
- state.storyResult = await charge("synthesizing", "", "/api/tracker/review/synthesize-story", body);
411
+ state.storyResult = await charge("synthesizing", "", body);
260
412
  state.verifyChunkIds = verificationGroups(state);
261
413
  state.stage = "verifying";
262
414
  } else if (state.stage === "verifying") {
263
415
  const id = state.verifyChunkIds.find(chunkId => !state.verifications[chunkId]);
264
416
  if (!id) { state.stage = "finalizing"; step--; continue; }
265
417
  const chunk = state.chunks.find(item => item.chunkId === id), analyzed = state.analyses[id];
266
- const media = await getMedia(chunk, analyzed.sha256);
267
- const response = await charge("verifying", id, "/api/tracker/review/verify-evidence", { story: state.storyResult.story, chunks: Object.values(state.analyses), chunk: media });
268
- if (response.chunkId !== id || !Array.isArray(response.verdicts) || !response.verdicts.length || response.verdicts.some(item => item.status !== "supported")) throw fail("REVIEW_ORIGINAL_VERIFICATION_FAILED", "원본 재검증에서 모순되거나 근거가 부족한 주장이 발견되었습니다. 전체 이해 완료나 자동 기획으로 진행하지 않습니다. 사용자가 줄거리·근거를 검토해야 합니다.");
418
+ const response = await charge("verifying", id, state.inFlight?.jobId ? null
419
+ : { story: state.storyResult.story, chunks: Object.values(state.analyses), chunkId: id },
420
+ { mediaReference: state.mediaReferences?.[id], getLegacyMedia: async () => ({ ...await getMedia(chunk, analyzed.sha256), workTitle: state.workTitle }) });
421
+ if (response.chunkId !== id || !Array.isArray(response.verdicts) || !response.verdicts.length
422
+ || response.verdicts.some(item => !["supported", "contradicted", "insufficient"].includes(item.status))) throw fail("REVIEW_ORIGINAL_VERIFICATION_INVALID", "저장된 원본 재검증 결과가 불완전합니다. 다시 과금하지 않습니다.");
269
423
  state.verifications[id] = response;
424
+ if (response.verdicts.some(item => item.status !== "supported")) {
425
+ state.reviewRequired = true;
426
+ throw fail("REVIEW_ORIGINAL_VERIFICATION_FAILED", "원본 재검증에서 모순되거나 근거가 부족한 주장이 발견되었습니다. 판정과 비용은 저장했습니다. 자동 재검증·전체 이해 완료·자동 기획으로 진행하지 않으며 사용자의 검토가 필요합니다.");
427
+ }
270
428
  } else if (state.stage === "finalizing") {
271
429
  const completed = { kind: "aimakeall-review-sources", version: 1, analysisScope: "full", analysisId: state.analysisId,
272
430
  sources: state.sources, sourceCatalog: Object.values(state.analyses).flatMap(chunk => chunk.sceneCatalog), globalStory: state.storyResult.story,
@@ -290,13 +448,18 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
290
448
  return summary(state, resumePath);
291
449
  } catch (error) {
292
450
  if (!state) throw error;
451
+ // Never attach another account's saved costs/results to the caller or write
452
+ // through its journal after PAT/server changes (including mid-request).
453
+ assertCheckpointAuthority(config, state);
293
454
  state.failedStage ||= state.stage; state.stage = "blocked";
294
455
  state.error = { code: error.errorCode || error.code || "REVIEW_FULL_ANALYSIS_FAILED", message: String(error.message || error) };
295
- if (state.pendingStageResult) {
456
+ if (state.pendingStageResult && state.inFlight?.transport !== REVIEW_STORY_TRANSPORT) {
296
457
  state.failedStageResultPath = savePayloadHandle(config.stateDir, { kind: "aimakeall-review-failed-stage", version: 1, analysisId: state.analysisId, stage: state.failedStage, result: state.pendingStageResult }, "review-failed-stage").filePath;
297
- delete state.pendingStageResult;
458
+ // Keep the received result in the journal too: a restart must apply it,
459
+ // never repeat the paid request (including legacy successful responses).
298
460
  }
299
461
  resumePath = snapshot(config, state);
300
- return { ...summary(state, resumePath), requiresExplicitRetryApproval: true };
462
+ return { ...summary(state, resumePath), requiresExplicitRetryApproval: !state.reviewRequired
463
+ && ![REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(state.inFlight?.transport) };
301
464
  } finally { release(); }
302
465
  }
@@ -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
+ }
@@ -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 { fullReviewStatus, runFullReviewAnalysis, selectReviewPlanningCatalog } from "./review-full-analysis.mjs";
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단계씩 계속하세요. 완료된 청크는 재과금하지 않으며 실패 재시도는 명시 승인 필요. 대표 프레임 점검만 원하면 analysisScope:'sample'을 명시하세요(전체 이해가 아님).",
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(fullReviewStatus(config, resumePath))));
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를 보이스 추천·리뷰 편집에 전달하세요.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimakeall-mcp",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
5
5
  "type": "module",
6
6
  "bin": {