aimakeall-mcp 0.9.0 → 0.10.0
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 +5 -4
- package/lib/audio-duration.mjs +57 -0
- package/lib/audio-duration.test.mjs +89 -0
- package/lib/cloud-tools.mjs +99 -99
- package/lib/companion-client.mjs +29 -14
- package/lib/companion-client.test.mjs +71 -0
- package/lib/config.mjs +1 -1
- package/lib/generation-jobs.mjs +39 -0
- package/lib/generation-jobs.test.mjs +34 -0
- package/lib/media-store.mjs +8 -3
- package/lib/media-store.test.mjs +21 -1
- package/lib/render-job-store.mjs +107 -31
- package/lib/render-job-store.test.mjs +115 -12
- package/lib/tool-contract.test.mjs +80 -0
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import { downloadRenderOutput } from "./companion-client.mjs";
|
|
8
|
+
|
|
9
|
+
function fixture(t) {
|
|
10
|
+
const dir = mkdtempSync(path.join(tmpdir(), "aio-download-test-"));
|
|
11
|
+
const originalFetch = globalThis.fetch;
|
|
12
|
+
t.after(() => {
|
|
13
|
+
globalThis.fetch = originalFetch;
|
|
14
|
+
rmSync(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("동시 같은 이름 다운로드는 독립 파일로 저장하고 기존 출력도 보존", async (t) => {
|
|
20
|
+
const dir = fixture(t);
|
|
21
|
+
writeFileSync(path.join(dir, "same.mp4"), "EXISTING");
|
|
22
|
+
globalThis.fetch = async (url) => {
|
|
23
|
+
const text = String(url).includes("job-a/") ? "AAAACCCC" : "BBBBDDDD";
|
|
24
|
+
return new Response(new ReadableStream({
|
|
25
|
+
start(controller) {
|
|
26
|
+
controller.enqueue(Buffer.from(text.slice(0, 4)));
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
controller.enqueue(Buffer.from(text.slice(4)));
|
|
29
|
+
controller.close();
|
|
30
|
+
}, 20);
|
|
31
|
+
},
|
|
32
|
+
}), { headers: { "content-disposition": 'attachment; filename="same.mp4"' } });
|
|
33
|
+
};
|
|
34
|
+
const results = await Promise.all([
|
|
35
|
+
downloadRenderOutput("http://mock.invalid", "job-a", dir),
|
|
36
|
+
downloadRenderOutput("http://mock.invalid", "job-b", dir),
|
|
37
|
+
]);
|
|
38
|
+
assert.ok(results.every((result) => !result.error), JSON.stringify(results));
|
|
39
|
+
assert.equal(new Set(results.map((result) => result.filePath)).size, 2);
|
|
40
|
+
assert.deepEqual(results.map((result) => readFileSync(result.filePath, "utf8")), ["AAAACCCC", "BBBBDDDD"]);
|
|
41
|
+
assert.deepEqual(results.map((result) => result.bytes), [8, 8]);
|
|
42
|
+
assert.equal(readFileSync(path.join(dir, "same.mp4"), "utf8"), "EXISTING");
|
|
43
|
+
assert.deepEqual(readdirSync(dir).sort(), ["same-2.mp4", "same-3.mp4", "same.mp4"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("중단된 다운로드는 자기 임시 파일·예약만 정리한다", async (t) => {
|
|
47
|
+
const dir = fixture(t);
|
|
48
|
+
writeFileSync(path.join(dir, "same.mp4"), "EXISTING");
|
|
49
|
+
globalThis.fetch = async () => new Response(new ReadableStream({
|
|
50
|
+
start(controller) {
|
|
51
|
+
controller.enqueue(Buffer.from("partial"));
|
|
52
|
+
setTimeout(() => controller.error(new Error("fixture connection lost")), 10);
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
const result = await downloadRenderOutput("http://mock.invalid", "job", dir, { suggestedName: "same.mp4" });
|
|
56
|
+
assert.match(result.error, /fixture connection lost/u);
|
|
57
|
+
assert.deepEqual(readdirSync(dir), ["same.mp4"]);
|
|
58
|
+
assert.equal(readFileSync(path.join(dir, "same.mp4"), "utf8"), "EXISTING");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("출력 폴더 오류는 일회성 다운로드 요청 전에 반환", async (t) => {
|
|
62
|
+
const dir = fixture(t);
|
|
63
|
+
const notDirectory = path.join(dir, "file");
|
|
64
|
+
writeFileSync(notDirectory, "keep");
|
|
65
|
+
let called = false;
|
|
66
|
+
globalThis.fetch = async () => { called = true; throw new Error("must not fetch"); };
|
|
67
|
+
const result = await downloadRenderOutput("http://mock.invalid", "job", notDirectory);
|
|
68
|
+
assert.ok(result.error);
|
|
69
|
+
assert.equal(called, false);
|
|
70
|
+
assert.equal(readFileSync(notDirectory, "utf8"), "keep");
|
|
71
|
+
});
|
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.
|
|
5
|
+
export const MCP_PROXY_VERSION = "0.10.0";
|
|
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";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const GENERATION_JOB_PATH = "/api/tracker/mcp/generation-jobs";
|
|
4
|
+
const JOB_ID = /^[a-zA-Z0-9_-]{16,80}$/;
|
|
5
|
+
|
|
6
|
+
export function assertGenerationJobId(id) {
|
|
7
|
+
if (!JOB_ID.test(String(id || ""))) throw new Error("유효한 generationJobId가 필요합니다.");
|
|
8
|
+
return id;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Submission never waits for a provider. The server owns the long-running work;
|
|
12
|
+
// both its result and accepted task IDs remain queryable after MCP restarts.
|
|
13
|
+
export async function submitGenerationJob(api, { kind, input, requestId = randomUUID(), includePreview = true }) {
|
|
14
|
+
assertGenerationJobId(requestId);
|
|
15
|
+
try {
|
|
16
|
+
const response = await api.request(GENERATION_JOB_PATH, {
|
|
17
|
+
method: "POST", body: { id: requestId, kind, input, includePreview }, timeoutMs: 30_000,
|
|
18
|
+
});
|
|
19
|
+
if (response?.job?.id !== requestId) throw new Error("생성 작업 접수 응답을 확인할 수 없습니다.");
|
|
20
|
+
return { generationJobId: requestId, status: response.job.status, kind, includePreview,
|
|
21
|
+
existing: response.existing === true, nextTool: "get_generation_job",
|
|
22
|
+
guidance: "생성 작업을 접수했습니다. get_generation_job으로 조회해 completed 결과를 확인한 뒤 다음 단계로 진행하세요. 같은 장면을 다시 생성하지 마세요." };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (Number(error?.status) >= 400 && Number(error?.status) < 500) throw error;
|
|
25
|
+
// A lost acceptance response does not establish failure: don't submit a new
|
|
26
|
+
// paid job. The caller retains the ID, and list_generation_jobs also recovers it.
|
|
27
|
+
return { generationJobId: requestId, status: "unknown", kind, includePreview,
|
|
28
|
+
nextTool: "get_generation_job",
|
|
29
|
+
guidance: "접수 응답이 끊겼습니다. 이 작업 ID 또는 list_generation_jobs로 먼저 확인하세요. 새 작업을 재생성하지 마세요.",
|
|
30
|
+
error: String(error?.message || error) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function fetchGenerationJob(api, generationJobId) {
|
|
35
|
+
const id = assertGenerationJobId(generationJobId);
|
|
36
|
+
const response = await api.request(`${GENERATION_JOB_PATH}/${encodeURIComponent(id)}`, { timeoutMs: 30_000 });
|
|
37
|
+
if (!response?.job || response.job.id !== id) throw new Error("생성 작업 상태 응답이 올바르지 않습니다.");
|
|
38
|
+
return response.job;
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { fetchGenerationJob, submitGenerationJob } from "./generation-jobs.mjs";
|
|
4
|
+
|
|
5
|
+
const id = "generation-client-test-0001";
|
|
6
|
+
test("submission returns a recoverable handle without waiting for a completed image or video", async () => {
|
|
7
|
+
const calls = [];
|
|
8
|
+
const api = { async request(route, options) { calls.push({ route, options }); return { job: { id, status: "running" } }; } };
|
|
9
|
+
const result = await submitGenerationJob(api, { requestId: id, kind: "image", input: { prompt: "rain" } });
|
|
10
|
+
assert.equal(result.generationJobId, id);
|
|
11
|
+
assert.equal(result.nextTool, "get_generation_job");
|
|
12
|
+
assert.equal(calls.length, 1);
|
|
13
|
+
assert.equal(calls[0].options.body.id, id);
|
|
14
|
+
assert.equal(calls[0].route, "/api/tracker/mcp/generation-jobs");
|
|
15
|
+
});
|
|
16
|
+
test("a lost acceptance response preserves the ID and never resubmits paid work automatically", async () => {
|
|
17
|
+
let attempts = 0;
|
|
18
|
+
const api = { async request() { attempts++; throw new TypeError("fetch failed"); } };
|
|
19
|
+
const result = await submitGenerationJob(api, { requestId: id, kind: "video", input: {} });
|
|
20
|
+
assert.equal(result.status, "unknown");
|
|
21
|
+
assert.equal(result.generationJobId, id);
|
|
22
|
+
assert.equal(attempts, 1);
|
|
23
|
+
});
|
|
24
|
+
test("authorization and capacity rejections remain actionable errors", async () => {
|
|
25
|
+
for (const status of [401, 403, 409, 429]) {
|
|
26
|
+
const error = Object.assign(new Error("rejected"), { status });
|
|
27
|
+
await assert.rejects(submitGenerationJob({ request: async () => { throw error; } }, { requestId: id, kind: "image", input: {} }), error);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
test("polls retrieve completed results, check response identity and reject path injection", async () => {
|
|
31
|
+
assert.equal((await fetchGenerationJob({ request: async () => ({ job: { id, status: "completed", result: { videoUrl: "test" } } }) }, id)).result.videoUrl, "test");
|
|
32
|
+
await assert.rejects(fetchGenerationJob({ request: async () => ({ job: { id: "another" } }) }, id));
|
|
33
|
+
await assert.rejects(fetchGenerationJob({ request: async () => { throw new Error("must not call"); } }, "../background-jobs"), /generationJobId/);
|
|
34
|
+
});
|
package/lib/media-store.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export function encodedDataUrlBytes(sourceBytes, mimeType = "application/octet-s
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// 입력 경로 검증 — 확장자 화이트리스트 + 존재 + 상한. 위반 시 한국어 예외.
|
|
45
|
-
export function assertAllowedInputFile(filePath, allowedExts, { kind = "파일" } = {}) {
|
|
45
|
+
export function assertAllowedInputFile(filePath, allowedExts, { kind = "파일", maxBytes = MAX_DATA_URL_SOURCE_BYTES } = {}) {
|
|
46
46
|
const resolved = String(filePath || "").trim();
|
|
47
47
|
if (!resolved) throw new Error(`${kind} 경로가 비어 있습니다.`);
|
|
48
48
|
const ext = path.extname(resolved).toLowerCase();
|
|
@@ -52,14 +52,19 @@ export function assertAllowedInputFile(filePath, allowedExts, { kind = "파일"
|
|
|
52
52
|
if (!existsSync(resolved)) throw new Error(`${kind}을(를) 찾을 수 없습니다: ${resolved}`);
|
|
53
53
|
const stats = statSync(resolved);
|
|
54
54
|
if (!stats.isFile()) throw new Error(`${kind}이(가) 일반 파일이 아닙니다: ${resolved}`);
|
|
55
|
-
if (stats.size >
|
|
55
|
+
if (stats.size > maxBytes) {
|
|
56
56
|
throw new Error(
|
|
57
|
-
`${kind}이(가) 너무 큽니다 (${Math.round(stats.size / 1024 / 1024)}MB > ${Math.round(
|
|
57
|
+
`${kind}이(가) 너무 큽니다 (${Math.round(stats.size / 1024 / 1024)}MB > ${Math.round(maxBytes / 1024 / 1024)}MB): ${resolved}`,
|
|
58
58
|
);
|
|
59
59
|
}
|
|
60
60
|
return { ext, size: stats.size };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// raw body 업로드는 base64/JSON 상한과 무관하다. 확장자·일반 파일 검증은 유지한다.
|
|
64
|
+
export function assertAllowedVideoUpload(filePath) {
|
|
65
|
+
return assertAllowedInputFile(filePath, ALLOWED_VIDEO_EXTS, { kind: "영상 파일", maxBytes: Infinity });
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
export function saveMediaBuffer(stateDir, name, buffer, { contentType = "" } = {}) {
|
|
64
69
|
const dir = mediaDir(stateDir);
|
|
65
70
|
mkdirSync(dir, { recursive: true });
|
package/lib/media-store.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync, utimesSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, utimesSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test, { after } from "node:test";
|
|
@@ -7,6 +7,7 @@ import test, { after } from "node:test";
|
|
|
7
7
|
import {
|
|
8
8
|
ALLOWED_IMAGE_EXTS,
|
|
9
9
|
assertAllowedInputFile,
|
|
10
|
+
assertAllowedVideoUpload,
|
|
10
11
|
encodedDataUrlBytes,
|
|
11
12
|
fileToDataUrl,
|
|
12
13
|
fileToImagePayload,
|
|
@@ -55,6 +56,25 @@ test("크기 상한 초과 파일은 거부", () => {
|
|
|
55
56
|
assert.throws(() => fileToDataUrl(big), /너무 큽니다/u);
|
|
56
57
|
});
|
|
57
58
|
|
|
59
|
+
test("스트리밍 MP4는 17MiB를 넘어도 허용하되 파일 종류 검증은 유지", () => {
|
|
60
|
+
const big = saveMediaBuffer(scratch, "large.mp4", Buffer.alloc(MAX_DATA_URL_SOURCE_BYTES + 1)).filePath;
|
|
61
|
+
assert.equal(assertAllowedVideoUpload(big).size, MAX_DATA_URL_SOURCE_BYTES + 1);
|
|
62
|
+
assert.throws(() => assertAllowedVideoUpload(join(scratch, "id_rsa")), /허용되지 않은/u);
|
|
63
|
+
assert.throws(() => assertAllowedVideoUpload(join(scratch, "missing.mp4")), /찾을 수 없습니다/u);
|
|
64
|
+
assert.throws(() => fileToDataUrl(big), /허용되지 않은/u);
|
|
65
|
+
// The binary upload exception must never lift the data-URL audio/image limit.
|
|
66
|
+
const audio = saveMediaBuffer(scratch, "large.wav", Buffer.alloc(MAX_DATA_URL_SOURCE_BYTES + 1)).filePath;
|
|
67
|
+
assert.throws(() => fileToDataUrl(audio), /너무 큽니다/u);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("publish_youtube는 스트리밍 전용 검사기를 사용한다", () => {
|
|
71
|
+
const source = readFileSync(new URL("./cloud-tools.mjs", import.meta.url), "utf8");
|
|
72
|
+
const publish = source.slice(source.indexOf('"publish_youtube"'), source.indexOf('"write_script"'));
|
|
73
|
+
assert.match(publish, /const stats = assertAllowedVideoUpload\(videoPath\)/u);
|
|
74
|
+
assert.doesNotMatch(publish, /assertAllowedInputFile\(/u);
|
|
75
|
+
assert.match(publish, /requestStreamUpload\(/u);
|
|
76
|
+
});
|
|
77
|
+
|
|
58
78
|
test("이미지 페이로드 변환 — {name, mimeType, base64}", () => {
|
|
59
79
|
const imgPath = join(scratch, "product.png");
|
|
60
80
|
writeFileSync(imgPath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
package/lib/render-job-store.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
// 렌더 잡 영속화 — npx 프록시가 재시작돼도 jobId 를 잃지 않도록
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// 잡별 디렉터리로 분리해 여러 프록시가 공유 배열을 read-modify-write 하며
|
|
7
|
+
// 다른 잡을 잃어버리지 않게 한다. 구버전 render-jobs.json 은 계속 읽되 덮어쓰지 않는다.
|
|
8
|
+
// 같은 잡의 서로 다른 필드도 별도 원자 파일로 저장해 동시 패치를 보존한다.
|
|
8
9
|
|
|
9
10
|
const MAX_ENTRIES = 50;
|
|
10
11
|
const ENTRY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -13,55 +14,130 @@ export function renderJobStorePath(stateDir) {
|
|
|
13
14
|
return path.join(stateDir, "render-jobs.json");
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
function
|
|
17
|
+
export function renderJobRecordsDir(stateDir) {
|
|
18
|
+
return path.join(stateDir, "render-jobs");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function digest(value) {
|
|
22
|
+
return createHash("sha256").update(String(value)).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function jobDirectory(stateDir, jobId) {
|
|
26
|
+
return path.join(renderJobRecordsDir(stateDir), digest(jobId));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readJson(filePath) {
|
|
17
30
|
try {
|
|
18
|
-
|
|
19
|
-
return Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
31
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
20
32
|
} catch {
|
|
21
|
-
return
|
|
33
|
+
return null;
|
|
22
34
|
}
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
function
|
|
37
|
+
function readDirectory(dir) {
|
|
38
|
+
try { return readdirSync(dir); } catch { return []; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeJsonAtomic(filePath, value) {
|
|
26
42
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
44
|
+
try {
|
|
45
|
+
writeFileSync(tmpPath, `${JSON.stringify(value)}\n`, { flag: "wx", mode: 0o600 });
|
|
46
|
+
renameSync(tmpPath, filePath);
|
|
47
|
+
} finally {
|
|
48
|
+
rmSync(tmpPath, { force: true });
|
|
49
|
+
}
|
|
32
50
|
}
|
|
33
51
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
const IDENTITY_FIELDS = new Set(["jobId", "createdAt", "createdAtMs"]);
|
|
53
|
+
|
|
54
|
+
function readStoredJob(dir, fallback = null) {
|
|
55
|
+
const record = readJson(path.join(dir, "record.json"));
|
|
56
|
+
let job = record?.job?.jobId ? record.job : fallback;
|
|
57
|
+
if (!job?.jobId) return null;
|
|
58
|
+
for (const name of readDirectory(dir)) {
|
|
59
|
+
if (!/^[a-f0-9]{64}\.field\.json$/u.test(name)) continue;
|
|
60
|
+
const patch = readJson(path.join(dir, name));
|
|
61
|
+
if (typeof patch?.field !== "string" || IDENTITY_FIELDS.has(patch.field)) continue;
|
|
62
|
+
job = { ...job, [patch.field]: patch.value };
|
|
63
|
+
}
|
|
64
|
+
return { job, order: String(record?.order || "") };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readAllJobs(stateDir) {
|
|
68
|
+
const legacy = readJson(renderJobStorePath(stateDir));
|
|
69
|
+
const entries = new Map();
|
|
70
|
+
for (const job of Array.isArray(legacy?.jobs) ? legacy.jobs : []) {
|
|
71
|
+
if (job?.jobId) entries.set(String(job.jobId), { job, order: "" });
|
|
72
|
+
}
|
|
73
|
+
for (const name of readDirectory(renderJobRecordsDir(stateDir))) {
|
|
74
|
+
if (!/^[a-f0-9]{64}$/u.test(name)) continue;
|
|
75
|
+
const dir = path.join(renderJobRecordsDir(stateDir), name);
|
|
76
|
+
const record = readJson(path.join(dir, "record.json"));
|
|
77
|
+
if (!record?.job?.jobId || digest(record.job.jobId) !== name) continue;
|
|
78
|
+
const entry = readStoredJob(dir);
|
|
79
|
+
if (entry) entries.set(String(entry.job.jobId), entry);
|
|
80
|
+
}
|
|
81
|
+
return [...entries.values()];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isRecent(job, now) {
|
|
85
|
+
return Boolean(job?.jobId) && now - (Number(job.createdAtMs) || 0) < ENTRY_TTL_MS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pruneExpiredRecords(stateDir, now) {
|
|
89
|
+
for (const name of readDirectory(renderJobRecordsDir(stateDir))) {
|
|
90
|
+
if (!/^[a-f0-9]{64}$/u.test(name)) continue;
|
|
91
|
+
const dir = path.join(renderJobRecordsDir(stateDir), name);
|
|
92
|
+
const record = readJson(path.join(dir, "record.json"));
|
|
93
|
+
if (record?.job?.jobId && !isRecent(record.job, now)) {
|
|
94
|
+
rmSync(dir, { force: true, recursive: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
38
97
|
}
|
|
39
98
|
|
|
40
99
|
export function recordRenderJob(stateDir, { jobId, projectTitle = "", statusUrl = "", outputUrl = "" }, now = Date.now()) {
|
|
41
|
-
const
|
|
42
|
-
const jobs = pruneEntries(readEntries(filePath), now);
|
|
43
|
-
jobs.push({
|
|
100
|
+
const job = {
|
|
44
101
|
createdAt: new Date(now).toISOString(),
|
|
45
102
|
createdAtMs: now,
|
|
46
103
|
jobId: String(jobId || ""),
|
|
47
104
|
outputUrl: String(outputUrl || ""),
|
|
48
105
|
projectTitle: String(projectTitle || ""),
|
|
49
106
|
statusUrl: String(statusUrl || ""),
|
|
107
|
+
};
|
|
108
|
+
if (!job.jobId) throw new Error("jobId가 필요합니다.");
|
|
109
|
+
pruneExpiredRecords(stateDir, now);
|
|
110
|
+
writeJsonAtomic(path.join(jobDirectory(stateDir, job.jobId), "record.json"), {
|
|
111
|
+
job,
|
|
112
|
+
order: process.hrtime.bigint().toString().padStart(24, "0"),
|
|
50
113
|
});
|
|
51
|
-
|
|
52
|
-
return jobs[jobs.length - 1];
|
|
114
|
+
return job;
|
|
53
115
|
}
|
|
54
116
|
|
|
55
117
|
export function listRenderJobs(stateDir, now = Date.now()) {
|
|
56
|
-
return
|
|
118
|
+
return readAllJobs(stateDir)
|
|
119
|
+
.filter(({ job }) => isRecent(job, now))
|
|
120
|
+
.sort((left, right) => Number(left.job.createdAtMs) - Number(right.job.createdAtMs)
|
|
121
|
+
|| left.order.localeCompare(right.order))
|
|
122
|
+
.slice(-MAX_ENTRIES)
|
|
123
|
+
.reverse()
|
|
124
|
+
.map(({ job }) => job);
|
|
57
125
|
}
|
|
58
126
|
|
|
59
127
|
export function updateRenderJob(stateDir, jobId, patch = {}, now = Date.now()) {
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
128
|
+
const id = String(jobId || "");
|
|
129
|
+
const legacy = readJson(renderJobStorePath(stateDir));
|
|
130
|
+
const fallback = (Array.isArray(legacy?.jobs) ? legacy.jobs : []).find((job) => job?.jobId === id);
|
|
131
|
+
const dir = jobDirectory(stateDir, id);
|
|
132
|
+
const entry = readStoredJob(dir, fallback);
|
|
133
|
+
if (!entry || !isRecent(entry.job, now)) return null;
|
|
134
|
+
// Migrate only this legacy job; leave the original shared file untouched.
|
|
135
|
+
if (!readJson(path.join(dir, "record.json"))?.job?.jobId) {
|
|
136
|
+
writeJsonAtomic(path.join(dir, "record.json"), { job: entry.job, order: entry.order });
|
|
137
|
+
}
|
|
138
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
139
|
+
if (IDENTITY_FIELDS.has(field)) continue;
|
|
140
|
+
writeJsonAtomic(path.join(dir, `${digest(field)}.field.json`), { field, value });
|
|
141
|
+
}
|
|
142
|
+
return readStoredJob(dir)?.job || null;
|
|
67
143
|
}
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
|
-
import test
|
|
6
|
+
import test from "node:test";
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
9
|
listRenderJobs,
|
|
9
10
|
recordRenderJob,
|
|
11
|
+
renderJobRecordsDir,
|
|
10
12
|
renderJobStorePath,
|
|
11
13
|
updateRenderJob,
|
|
12
14
|
} from "./render-job-store.mjs";
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
function fixture(t) {
|
|
17
|
+
const stateDir = mkdtempSync(join(tmpdir(), "aio-mcp-jobstore-"));
|
|
18
|
+
t.after(() => rmSync(stateDir, { force: true, recursive: true }));
|
|
19
|
+
return stateDir;
|
|
20
|
+
}
|
|
16
21
|
|
|
17
|
-
test("기록 → 목록(최신순) → 갱신 roundtrip", () => {
|
|
22
|
+
test("기록 → 목록(최신순) → 갱신 roundtrip", (t) => {
|
|
23
|
+
const stateDir = fixture(t);
|
|
18
24
|
recordRenderJob(stateDir, { jobId: "job-1", projectTitle: "첫번째" });
|
|
19
25
|
recordRenderJob(stateDir, { jobId: "job-2", projectTitle: "두번째", statusUrl: "/api/moviepy/render-jobs/job-2" });
|
|
20
26
|
|
|
@@ -29,18 +35,115 @@ test("기록 → 목록(최신순) → 갱신 roundtrip", () => {
|
|
|
29
35
|
assert.equal(updateRenderJob(stateDir, "missing", { status: "x" }), null);
|
|
30
36
|
});
|
|
31
37
|
|
|
32
|
-
test("24시간 지난 항목은 정리된다", () => {
|
|
38
|
+
test("24시간 지난 항목은 정리된다", (t) => {
|
|
39
|
+
const stateDir = fixture(t);
|
|
33
40
|
const now = Date.now();
|
|
34
41
|
recordRenderJob(stateDir, { jobId: "old-job" }, now - 25 * 60 * 60 * 1000);
|
|
35
42
|
const jobs = listRenderJobs(stateDir, now);
|
|
36
43
|
assert.equal(jobs.some((job) => job.jobId === "old-job"), false);
|
|
44
|
+
recordRenderJob(stateDir, { jobId: "new-job" }, now);
|
|
45
|
+
assert.equal(readdirSync(renderJobRecordsDir(stateDir)).length, 1);
|
|
37
46
|
});
|
|
38
47
|
|
|
39
|
-
test("손상된
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
rmSync(filePath);
|
|
48
|
+
test("손상된 legacy 파일이나 잡 레코드는 다른 잡을 지우지 않는다", (t) => {
|
|
49
|
+
const stateDir = fixture(t);
|
|
50
|
+
writeFileSync(renderJobStorePath(stateDir), "invalid json");
|
|
43
51
|
assert.deepEqual(listRenderJobs(stateDir), []);
|
|
44
|
-
recordRenderJob(stateDir, { jobId: "
|
|
45
|
-
|
|
52
|
+
recordRenderJob(stateDir, { jobId: "broken" });
|
|
53
|
+
const firstDir = readdirSync(renderJobRecordsDir(stateDir))[0];
|
|
54
|
+
writeFileSync(join(renderJobRecordsDir(stateDir), firstDir, "record.json"), "invalid json");
|
|
55
|
+
recordRenderJob(stateDir, { jobId: "healthy" });
|
|
56
|
+
assert.deepEqual(listRenderJobs(stateDir).map((job) => job.jobId), ["healthy"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("legacy 목록을 보존하면서 새 저장소에 갱신하고 중복 없이 병합", (t) => {
|
|
60
|
+
const stateDir = fixture(t);
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const legacy = JSON.stringify({ jobs: [
|
|
63
|
+
{ jobId: "legacy-1", createdAtMs: now - 2, projectTitle: "old title" },
|
|
64
|
+
{ jobId: "legacy-2", createdAtMs: now - 1 },
|
|
65
|
+
] });
|
|
66
|
+
writeFileSync(renderJobStorePath(stateDir), legacy);
|
|
67
|
+
recordRenderJob(stateDir, { jobId: "new" }, now);
|
|
68
|
+
updateRenderJob(stateDir, "legacy-1", { status: "downloaded", filePath: "/tmp/kept.mp4" }, now);
|
|
69
|
+
const jobs = listRenderJobs(stateDir, now);
|
|
70
|
+
assert.equal(jobs.length, 3);
|
|
71
|
+
assert.equal(jobs.find((job) => job.jobId === "legacy-1").filePath, "/tmp/kept.mp4");
|
|
72
|
+
assert.equal(jobs.find((job) => job.jobId === "legacy-1").projectTitle, "old title");
|
|
73
|
+
assert.equal(readFileSync(renderJobStorePath(stateDir), "utf8"), legacy);
|
|
74
|
+
rmSync(renderJobStorePath(stateDir));
|
|
75
|
+
assert.equal(listRenderJobs(stateDir, now).find((job) => job.jobId === "legacy-1").status, "downloaded");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("최신 50개 상한과 jobId 경로 격리를 유지", (t) => {
|
|
79
|
+
const stateDir = fixture(t);
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
for (let i = 0; i < 55; i++) recordRenderJob(stateDir, { jobId: `../job-${i}` }, now + i);
|
|
82
|
+
const jobs = listRenderJobs(stateDir, now + 55);
|
|
83
|
+
assert.equal(jobs.length, 50);
|
|
84
|
+
assert.equal(jobs[0].jobId, "../job-54");
|
|
85
|
+
assert.equal(jobs.at(-1).jobId, "../job-5");
|
|
86
|
+
assert.ok(readdirSync(renderJobRecordsDir(stateDir)).every((name) => /^[a-f0-9]{64}$/u.test(name)));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Start independent Node processes at the same barrier, not sequential promises
|
|
90
|
+
// in one event loop (the old synchronous read-modify-write passed those tests).
|
|
91
|
+
async function concurrentWorkers(t, stateDir, operation, count = 8) {
|
|
92
|
+
const moduleUrl = new URL("./render-job-store.mjs", import.meta.url).href;
|
|
93
|
+
const workers = Array.from({ length: count }, (_, index) => {
|
|
94
|
+
const source = `
|
|
95
|
+
import { recordRenderJob, updateRenderJob } from ${JSON.stringify(moduleUrl)};
|
|
96
|
+
const stateDir = ${JSON.stringify(stateDir)};
|
|
97
|
+
const worker = ${index};
|
|
98
|
+
process.stdin.once('data', () => {
|
|
99
|
+
${operation}
|
|
100
|
+
process.stdin.destroy();
|
|
101
|
+
});
|
|
102
|
+
process.stdout.write('ready');
|
|
103
|
+
`;
|
|
104
|
+
const child = spawn(process.execPath, ["--input-type=module", "--eval", source], { stdio: ["pipe", "pipe", "pipe"] });
|
|
105
|
+
t.after(() => { if (child.exitCode === null) child.kill(); });
|
|
106
|
+
let stderr = "";
|
|
107
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
108
|
+
const ready = new Promise((resolve, reject) => {
|
|
109
|
+
child.stdout.once("data", resolve);
|
|
110
|
+
child.once("error", reject);
|
|
111
|
+
child.once("exit", (code) => { if (code !== 0) reject(new Error(stderr || `worker exit ${code}`)); });
|
|
112
|
+
});
|
|
113
|
+
const done = new Promise((resolve, reject) => {
|
|
114
|
+
child.once("error", reject);
|
|
115
|
+
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(stderr || `worker exit ${code}`)));
|
|
116
|
+
});
|
|
117
|
+
return { child, ready, done };
|
|
118
|
+
});
|
|
119
|
+
await Promise.all(workers.map(({ ready }) => ready));
|
|
120
|
+
for (const { child } of workers) child.stdin.end("go");
|
|
121
|
+
await Promise.all(workers.map(({ done }) => done));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test("8개 프로세스가 동시에 기록·갱신해도 모든 잡이 보존된다", async (t) => {
|
|
125
|
+
const stateDir = fixture(t);
|
|
126
|
+
await concurrentWorkers(t, stateDir, `
|
|
127
|
+
for (let i = 0; i < 4; i++) {
|
|
128
|
+
const jobId = 'worker-' + worker + '-job-' + i;
|
|
129
|
+
recordRenderJob(stateDir, { jobId });
|
|
130
|
+
updateRenderJob(stateDir, jobId, { status: 'succeeded' });
|
|
131
|
+
}
|
|
132
|
+
`);
|
|
133
|
+
const jobs = listRenderJobs(stateDir);
|
|
134
|
+
assert.equal(jobs.length, 32);
|
|
135
|
+
assert.equal(new Set(jobs.map((job) => job.jobId)).size, 32);
|
|
136
|
+
assert.ok(jobs.every((job) => job.status === "succeeded"));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("같은 잡의 서로 다른 필드에 대한 프로세스 동시 패치를 보존", async (t) => {
|
|
140
|
+
const stateDir = fixture(t);
|
|
141
|
+
recordRenderJob(stateDir, { jobId: "shared" });
|
|
142
|
+
await concurrentWorkers(t, stateDir, `
|
|
143
|
+
updateRenderJob(stateDir, 'shared', { ['field-' + worker]: 'value-' + worker });
|
|
144
|
+
`);
|
|
145
|
+
const [job] = listRenderJobs(stateDir);
|
|
146
|
+
for (let i = 0; i < 8; i++) assert.equal(job[`field-${i}`], `value-${i}`);
|
|
147
|
+
updateRenderJob(stateDir, "shared", { status: "downloaded", filePath: "/tmp/shared.mp4", jobId: "other" });
|
|
148
|
+
assert.equal(listRenderJobs(stateDir)[0].jobId, "shared");
|
|
46
149
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// MCP 도구 ↔ 서버 계약 회귀.
|
|
2
|
+
// 감사에서 확인된 실제 결함들을 고정한다 — 전부 "테스트는 통과하는데 사용자에겐 깨져 보이던" 종류다.
|
|
3
|
+
//
|
|
4
|
+
// 이 파일은 zod·cloud-tools 를 import 하지 않는다. mcp/ 는 별도 npm 패키지라 그 의존성이
|
|
5
|
+
// 저장소 루트의 npm ci 에는 없고, 배포 게이트(prep.sh)는 루트에서 npm test 를 돌린다.
|
|
6
|
+
// 다른 mcp 테스트들도 같은 이유로 의존성 없이 작성돼 있다.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
|
|
11
|
+
const SOURCE = readFileSync(new URL("./cloud-tools.mjs", import.meta.url), "utf8");
|
|
12
|
+
|
|
13
|
+
/** 특정 api.request 호출 뒤에 붙은 timeoutMs 값을 읽는다. */
|
|
14
|
+
function clientBudgetFor(routePath) {
|
|
15
|
+
const at = SOURCE.indexOf(`"${routePath}"`);
|
|
16
|
+
assert.ok(at > 0, `${routePath} 호출을 찾지 못했다`);
|
|
17
|
+
const found = SOURCE.slice(at, at + 2600).match(/timeoutMs:\s*([0-9_]+)/);
|
|
18
|
+
assert.ok(found, `${routePath}: timeoutMs 를 찾지 못했다`);
|
|
19
|
+
return Number(found[1].replace(/_/g, ""));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("stitch_timeline 전환 기본값은 none — fade 는 맞대기 배치에서 컷마다 검은 딥을 만든다", () => {
|
|
23
|
+
// 0.8.0 이 서버·웹 기본값만 none 으로 바꾸고 MCP 를 놓쳐 MCP 렌더에만 딥이 남아 있었다.
|
|
24
|
+
// MCP 는 transitionMode 를 항상 명시 전송하므로 서버 기본값이 적용되지 않는다.
|
|
25
|
+
assert.match(SOURCE, /transitionMode = "none"/, "MCP 핸들러 기본값이 none 이 아니다");
|
|
26
|
+
assert.doesNotMatch(SOURCE, /transitionMode = "fade"/, "fade 기본값이 남아 있다");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("대본 도구의 targetLength 상한이 서버 출력 상한 안에 있다", () => {
|
|
30
|
+
// 서버는 max_tokens 8192 고정이라 한국어 약 12,300자가 한계다. 20000 을 광고하면
|
|
31
|
+
// 프롬프트에 도달 불가한 목표가 나가고, 잘림 감지가 없어 끊긴 대본이 정상 반환된다.
|
|
32
|
+
assert.doesNotMatch(SOURCE, /targetLength: z\.number\(\)\.int\(\)\.min\(500\)\.max\(20000\)/,
|
|
33
|
+
"도달 불가한 20000 상한이 남아 있다");
|
|
34
|
+
const caps = [...SOURCE.matchAll(/targetLength: z\.number\(\)\.int\(\)\.min\(500\)\.max\((\d+)\)/g)]
|
|
35
|
+
.map((m) => Number(m[1]));
|
|
36
|
+
assert.equal(caps.length, 3, `대본 도구 3종을 찾지 못했다 (${caps.length}개)`);
|
|
37
|
+
for (const cap of caps) assert.ok(cap <= 12000, `상한 ${cap} 이 서버 한계를 넘는다`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("지문 파라미터는 sampleSize 를 요구한다 — 없으면 서버가 조용히 무시한다", () => {
|
|
41
|
+
// 서버 buildFingerprintDirectives 는 sampleSize > 0 이 아니면 빈 배열을 반환한다.
|
|
42
|
+
// 선택 필드로 두면 부분 지문이 통과해 아무 일도 안 일어나는데 성공처럼 보인다.
|
|
43
|
+
assert.doesNotMatch(SOURCE, /sampleSize: z\.number\(\)\.optional\(\)/,
|
|
44
|
+
"sampleSize 가 아직 선택 필드다");
|
|
45
|
+
const required = [...SOURCE.matchAll(/sampleSize: z\.number\(\)\.describe\(/g)].length;
|
|
46
|
+
assert.ok(required >= 2, `sampleSize 필수 선언이 부족하다 (${required}곳)`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("measure_channel_spec 은 재지 않는 축(말속도)을 약속하지 않는다", () => {
|
|
50
|
+
// cpsTarget 은 타임코드 전사가 있어야 산출되는데 이 도구는 전사를 하지 않는다.
|
|
51
|
+
const at = SOURCE.indexOf('"measure_channel_spec"');
|
|
52
|
+
assert.ok(at > 0);
|
|
53
|
+
const block = SOURCE.slice(at, at + 1200);
|
|
54
|
+
assert.match(block, /말속도는 측정하지 않습니다/, "재지 않는 축을 약속하고 있다");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("대본 도구의 클라이언트 예산이 서버 최악 예산보다 크다", () => {
|
|
58
|
+
// 역전되면 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다 — 회수 불가능한 지출.
|
|
59
|
+
// 서버 최악: 리서치 8초 + 생성 150초 × 2회 ≈ 310초.
|
|
60
|
+
for (const route of [
|
|
61
|
+
"/api/tracker/ai/script",
|
|
62
|
+
"/api/tracker/ai/real-estate-report",
|
|
63
|
+
"/api/tracker/ai/market-analysis",
|
|
64
|
+
]) {
|
|
65
|
+
const budget = clientBudgetFor(route);
|
|
66
|
+
assert.ok(budget >= 400_000, `${route}: 클라이언트 예산 ${budget}ms 가 서버 최악보다 작다`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("이 테스트 파일은 mcp 전용 의존성을 import 하지 않는다", () => {
|
|
71
|
+
// 배포 게이트는 저장소 루트에서 npm test 를 돌리는데 zod 는 mcp/ 에만 있다.
|
|
72
|
+
// (실제로 이 규칙을 어겨 배포가 한 번 막혔다.)
|
|
73
|
+
const self = readFileSync(new URL("./tool-contract.test.mjs", import.meta.url), "utf8");
|
|
74
|
+
const importLines = self.split("\n").filter((line) => /^\s*import\s/.test(line));
|
|
75
|
+
for (const line of importLines) {
|
|
76
|
+
assert.doesNotMatch(line, /["']zod["']/, `zod 를 import 하면 루트 npm test 에서 깨진다: ${line.trim()}`);
|
|
77
|
+
assert.doesNotMatch(line, /cloud-tools\.mjs/, `cloud-tools 는 zod 를 import 하므로 같이 깨진다: ${line.trim()}`);
|
|
78
|
+
}
|
|
79
|
+
assert.ok(importLines.length >= 3, "import 문을 찾지 못했다 — 검사가 무력화됐다");
|
|
80
|
+
});
|