@lumenkey/cli 0.3.1 → 0.4.0-rc.1
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 +36 -2
- package/dist/cli/copied.js +86 -85
- package/dist/cli/index.js +56 -44
- package/dist/cli/render.js +3 -2
- package/dist/core/asset-stream.d.ts +16 -0
- package/dist/core/asset-stream.js +64 -0
- package/dist/core/atomic-config-file.js +8 -7
- package/dist/core/chinese-messages.d.ts +4 -0
- package/dist/core/chinese-messages.js +907 -0
- package/dist/core/chinese-script.d.ts +7 -0
- package/dist/core/chinese-script.js +28 -0
- package/dist/core/client.js +2 -1
- package/dist/core/config-dir.js +2 -1
- package/dist/core/config.js +7 -6
- package/dist/core/download-root.d.ts +5 -0
- package/dist/core/download-root.js +64 -0
- package/dist/core/download.js +15 -12
- package/dist/core/generation-input.js +3 -2
- package/dist/core/generation.d.ts +8 -1
- package/dist/core/generation.js +13 -10
- package/dist/core/job-display.js +7 -6
- package/dist/core/locale.js +163 -148
- package/dist/core/outcome.js +6 -4
- package/dist/core/output-path.d.ts +3 -1
- package/dist/core/output-path.js +5 -2
- package/dist/core/params.js +26 -25
- package/dist/core/profiles.js +5 -4
- package/dist/core/set-values.d.ts +0 -1
- package/dist/core/set-values.js +7 -7
- package/dist/core/submission-journal.js +31 -16
- package/dist/core/version.js +2 -3
- package/dist/mcp/server.js +30 -29
- package/dist/studio/asset-integrity-hash.d.ts +28 -0
- package/dist/studio/asset-integrity-hash.js +93 -0
- package/dist/studio/asset-integrity-queue.d.ts +24 -0
- package/dist/studio/asset-integrity-queue.js +94 -0
- package/dist/studio/asset-store.d.ts +52 -0
- package/dist/studio/asset-store.js +352 -0
- package/dist/studio/canvas/LICENSES.txt +1590 -0
- package/dist/studio/canvas/dependencies.json +862 -0
- package/dist/studio/canvas/director-source-manifest.json +47 -0
- package/dist/studio/canvas/editor.css +239 -0
- package/dist/studio/canvas/editor.js +4613 -0
- package/dist/studio/canvas/integration-source-manifest.json +384 -0
- package/dist/studio/canvas/source-manifest.json +240 -0
- package/dist/studio/canvas-store.d.ts +1254 -0
- package/dist/studio/canvas-store.js +205 -0
- package/dist/studio/capture-placement.d.ts +11 -0
- package/dist/studio/capture-placement.js +85 -0
- package/dist/studio/cloud-contract.d.ts +128 -0
- package/dist/studio/cloud-contract.js +229 -0
- package/dist/studio/cloud-service.d.ts +178 -0
- package/dist/studio/cloud-service.js +202 -0
- package/dist/studio/director-captures.d.ts +59 -0
- package/dist/studio/director-captures.js +141 -0
- package/dist/studio/director-contract.d.ts +1489 -0
- package/dist/studio/director-contract.js +288 -0
- package/dist/studio/director-png.d.ts +6 -0
- package/dist/studio/director-png.js +41 -0
- package/dist/studio/director-relations.d.ts +40 -0
- package/dist/studio/director-relations.js +103 -0
- package/dist/studio/document-authority.d.ts +7 -0
- package/dist/studio/document-authority.js +50 -0
- package/dist/studio/draft-store.d.ts +95 -0
- package/dist/studio/draft-store.js +100 -0
- package/dist/studio/errors.d.ts +85 -0
- package/dist/studio/errors.js +94 -0
- package/dist/studio/launch.d.ts +5 -0
- package/dist/studio/launch.js +31 -0
- package/dist/studio/media.d.ts +9 -0
- package/dist/studio/media.js +50 -0
- package/dist/studio/output-save-contract.d.ts +1321 -0
- package/dist/studio/output-save-contract.js +26 -0
- package/dist/studio/output-save-journal.d.ts +971 -0
- package/dist/studio/output-save-journal.js +287 -0
- package/dist/studio/output-save-recovery.d.ts +18 -0
- package/dist/studio/output-save-recovery.js +161 -0
- package/dist/studio/output-save-service.d.ts +436 -0
- package/dist/studio/output-save-service.js +251 -0
- package/dist/studio/output-save-worker.d.ts +40 -0
- package/dist/studio/output-save-worker.js +360 -0
- package/dist/studio/paths.d.ts +6 -0
- package/dist/studio/paths.js +68 -0
- package/dist/studio/run-journal.d.ts +993 -0
- package/dist/studio/run-journal.js +125 -0
- package/dist/studio/server.d.ts +17 -0
- package/dist/studio/server.js +440 -0
- package/dist/studio/session.d.ts +6 -0
- package/dist/studio/session.js +26 -0
- package/dist/studio/shell.d.ts +3 -0
- package/dist/studio/shell.js +23 -0
- package/dist/studio/style.d.ts +2 -0
- package/dist/studio/style.js +29 -0
- package/dist/studio/types.d.ts +26 -0
- package/dist/studio/types.js +2 -0
- package/dist/studio/web/app.d.ts +2 -0
- package/dist/studio/web/app.js +662 -0
- package/dist/studio/web/asset-controller.d.ts +37 -0
- package/dist/studio/web/asset-controller.js +191 -0
- package/dist/studio/web/asset-model.d.ts +29 -0
- package/dist/studio/web/asset-model.js +51 -0
- package/dist/studio/web/controller.d.ts +113 -0
- package/dist/studio/web/controller.js +401 -0
- package/dist/studio/web/messages.d.ts +2 -0
- package/dist/studio/web/messages.js +74 -0
- package/dist/studio/web/model.d.ts +75 -0
- package/dist/studio/web/model.js +182 -0
- package/dist/studio/web/save-controller.d.ts +85 -0
- package/dist/studio/web/save-controller.js +495 -0
- package/dist/studio/web/save-model.d.ts +63 -0
- package/dist/studio/web/save-model.js +64 -0
- package/dist/studio/web/save-queue.d.ts +7 -0
- package/dist/studio/web/save-queue.js +62 -0
- package/dist/studio/web/save-view.d.ts +9 -0
- package/dist/studio/web/save-view.js +19 -0
- package/dist/studio/writer-lock.d.ts +5 -0
- package/dist/studio/writer-lock.js +88 -0
- package/package.json +11 -5
- package/dist/cli/copied.d.ts.map +0 -1
- package/dist/cli/copied.js.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/render.d.ts.map +0 -1
- package/dist/cli/render.js.map +0 -1
- package/dist/core/atomic-config-file.d.ts.map +0 -1
- package/dist/core/atomic-config-file.js.map +0 -1
- package/dist/core/chat.d.ts.map +0 -1
- package/dist/core/chat.js.map +0 -1
- package/dist/core/client.d.ts.map +0 -1
- package/dist/core/client.js.map +0 -1
- package/dist/core/config-dir.d.ts.map +0 -1
- package/dist/core/config-dir.js.map +0 -1
- package/dist/core/config-error.d.ts.map +0 -1
- package/dist/core/config-error.js.map +0 -1
- package/dist/core/config.d.ts.map +0 -1
- package/dist/core/config.js.map +0 -1
- package/dist/core/credit-cap.d.ts.map +0 -1
- package/dist/core/credit-cap.js.map +0 -1
- package/dist/core/download.d.ts.map +0 -1
- package/dist/core/download.js.map +0 -1
- package/dist/core/error-result.d.ts.map +0 -1
- package/dist/core/error-result.js.map +0 -1
- package/dist/core/explain.d.ts.map +0 -1
- package/dist/core/explain.js.map +0 -1
- package/dist/core/generation-input.d.ts.map +0 -1
- package/dist/core/generation-input.js.map +0 -1
- package/dist/core/generation-result.d.ts.map +0 -1
- package/dist/core/generation-result.js.map +0 -1
- package/dist/core/generation.d.ts.map +0 -1
- package/dist/core/generation.js.map +0 -1
- package/dist/core/job-display.d.ts.map +0 -1
- package/dist/core/job-display.js.map +0 -1
- package/dist/core/locale.d.ts.map +0 -1
- package/dist/core/locale.js.map +0 -1
- package/dist/core/origin.d.ts.map +0 -1
- package/dist/core/origin.js.map +0 -1
- package/dist/core/outcome.d.ts.map +0 -1
- package/dist/core/outcome.js.map +0 -1
- package/dist/core/output-path.d.ts.map +0 -1
- package/dist/core/output-path.js.map +0 -1
- package/dist/core/params.d.ts.map +0 -1
- package/dist/core/params.js.map +0 -1
- package/dist/core/poller.d.ts.map +0 -1
- package/dist/core/poller.js.map +0 -1
- package/dist/core/profiles.d.ts.map +0 -1
- package/dist/core/profiles.js.map +0 -1
- package/dist/core/set-values.d.ts.map +0 -1
- package/dist/core/set-values.js.map +0 -1
- package/dist/core/submission-journal.d.ts.map +0 -1
- package/dist/core/submission-journal.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/version.d.ts.map +0 -1
- package/dist/core/version.js.map +0 -1
- package/dist/core/wait.d.ts.map +0 -1
- package/dist/core/wait.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mcp/render.d.ts.map +0 -1
- package/dist/mcp/render.js.map +0 -1
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/mcp/server.js.map +0 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { assetFetch } from '../core/client.js';
|
|
2
|
+
import type { LumenKeyConfig } from '../core/types.js';
|
|
3
|
+
import type { StudioAssetStore } from './asset-store.js';
|
|
4
|
+
import type { SaveOperation } from './output-save-contract.js';
|
|
5
|
+
export interface SaveWorkerHooks {
|
|
6
|
+
afterAcceptedCommit?(): void | Promise<void>;
|
|
7
|
+
duringDownload?(bytes: number): void | Promise<void>;
|
|
8
|
+
afterLinkBeforeUnlink?(): void | Promise<void>;
|
|
9
|
+
afterFilePublishBeforeCheckpoint?(): void | Promise<void>;
|
|
10
|
+
beforeCatalogCommit?(): void | Promise<void>;
|
|
11
|
+
afterCatalogBeforeRelation?(): void | Promise<void>;
|
|
12
|
+
afterRelationBeforeResponse?(): void | Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface OutputSaveWorker {
|
|
15
|
+
enqueue(id: string): void;
|
|
16
|
+
idle(): Promise<void>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
recover(): Promise<void>;
|
|
19
|
+
retry(id: string, connectionId: string, revision: number): Promise<SaveOperation>;
|
|
20
|
+
suspend(connectionId: string): void;
|
|
21
|
+
activate(connectionId: string): void;
|
|
22
|
+
progress(id: string, revision: number, attempt: number): {
|
|
23
|
+
receivedBytes: number;
|
|
24
|
+
totalBytes: number | null;
|
|
25
|
+
} | null;
|
|
26
|
+
}
|
|
27
|
+
/** Serial executor per root writer. Transport and file IO never hold its journal queue.
|
|
28
|
+
* Construction is internal DI; HTTP cannot choose a transport, path or fault hook. */
|
|
29
|
+
export declare function createOutputSaveWorker(options: {
|
|
30
|
+
store: StudioAssetStore;
|
|
31
|
+
outputDir: string;
|
|
32
|
+
maxBytes?: number;
|
|
33
|
+
fetchAsset?: typeof assetFetch;
|
|
34
|
+
hooks?: SaveWorkerHooks;
|
|
35
|
+
loadConfig?(): LumenKeyConfig | Promise<LumenKeyConfig>;
|
|
36
|
+
refreshJob?(operation: SaveOperation, signal: AbortSignal): Promise<unknown>;
|
|
37
|
+
/** Internal clock DI only; production deadline is always 120000ms. */
|
|
38
|
+
armDeadline?(milliseconds: number, expire: () => void): () => void;
|
|
39
|
+
}): OutputSaveWorker;
|
|
40
|
+
//# sourceMappingURL=output-save-worker.d.ts.map
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { assetFetch, LumenKeyError, LumenKeyClient } from '../core/client.js';
|
|
4
|
+
import { studioBindingDigest } from './cloud-service.js';
|
|
5
|
+
import { streamAssetBodyToFile } from '../core/asset-stream.js';
|
|
6
|
+
import { cloudDiagnostic, projectOutputViews, projectJob } from './cloud-contract.js';
|
|
7
|
+
import { detectStudioMediaType, mediaExtensions } from './media.js';
|
|
8
|
+
import { assertStudioRoot, prepareStudioRoot } from './paths.js';
|
|
9
|
+
import { hasFsCode, StudioError } from './errors.js';
|
|
10
|
+
import { reconcilePublishedSaveFile, reconcilePartialSaveFile } from './output-save-recovery.js';
|
|
11
|
+
import { SaveIntegrityPending } from './output-save-journal.js';
|
|
12
|
+
const workers = new WeakMap();
|
|
13
|
+
const workerCodes = ['SAVE_REGISTRATION_PENDING', 'SAVE_RELATION_PENDING', 'SAVE_AUTHORITY_INVALID', 'SAVE_AUTHORITY_UNAVAILABLE', 'SAVE_BINDING_REQUIRED', 'SAVE_BINDING_MISMATCH', 'SAVE_PATH_UNSAFE', 'SAVE_TARGET_CONFLICT', 'SAVE_WRITE_FAILED', 'SAVE_NETWORK_ERROR', 'SAVE_DOWNLOAD_TIMEOUT', 'SAVE_MEDIA_TOO_LARGE', 'SAVE_MEDIA_UNSUPPORTED', 'SAVE_LENGTH_MISMATCH', 'SAVE_SOURCE_UNAVAILABLE', 'SAVE_OUTPUT_IDENTITY_CONFLICT', 'SAVE_CLOUD_CONFIG_REQUIRED', 'SAVE_REVISION_CONFLICT'];
|
|
14
|
+
function workerCode(code) { return workerCodes.find(value => value === code) ?? 'SAVE_WRITE_FAILED'; }
|
|
15
|
+
function pendingCode(op) { return !op.transfer?.completed ? null : op.receipt.facts.registration === 'ABSENT' ? 'SAVE_REGISTRATION_PENDING' : op.receipt.facts.registration === 'PRESENT' && op.receipt.facts.relation === 'ABSENT' ? 'SAVE_RELATION_PENDING' : null; }
|
|
16
|
+
function diagnostic(code, retryable, mayAlreadyBeSaved) { return { ...cloudDiagnostic(new StudioError(code, 409)), retryable, mayAlreadyBeSaved }; }
|
|
17
|
+
/** Serial executor per root writer. Transport and file IO never hold its journal queue.
|
|
18
|
+
* Construction is internal DI; HTTP cannot choose a transport, path or fault hook. */
|
|
19
|
+
export function createOutputSaveWorker(options) {
|
|
20
|
+
const existing = workers.get(options.store);
|
|
21
|
+
if (existing)
|
|
22
|
+
return existing;
|
|
23
|
+
const limit = Math.min(128 * 1024 * 1024, options.store.maxBytes, options.maxBytes ?? 128 * 1024 * 1024);
|
|
24
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
25
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
26
|
+
let queue = Promise.resolve();
|
|
27
|
+
let closed = false;
|
|
28
|
+
let controller;
|
|
29
|
+
let closePromise;
|
|
30
|
+
let activeConnection;
|
|
31
|
+
const suspended = new Set();
|
|
32
|
+
const scheduled = new Set();
|
|
33
|
+
const progress = new Map();
|
|
34
|
+
const failures = [];
|
|
35
|
+
async function recoveryFailure(op, code, published = false) {
|
|
36
|
+
if (code !== 'SAVE_WRITE_FAILED' || !op.transfer?.completed || (!published && !['FILE_READY', 'REGISTERED'].includes(op.receipt.stage)))
|
|
37
|
+
return { code, safeRetry: false };
|
|
38
|
+
// Classifying a fault must not publish or unlink: recovery owns those writes.
|
|
39
|
+
try {
|
|
40
|
+
await reconcilePublishedSaveFile(options.outputDir, op, limit, undefined, true);
|
|
41
|
+
const pending = pendingCode(op);
|
|
42
|
+
return pending ? { code: pending, safeRetry: true } : { code, safeRetry: false };
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return { code: workerCode(error instanceof StudioError ? error.code : 'SAVE_WRITE_FAILED'), safeRetry: false };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function localRecovery(original) {
|
|
49
|
+
// Observation is read-only diagnostics but temporarily disables safeRetry.
|
|
50
|
+
// Do not reconcile through a live check or reinterpret it as a save fault.
|
|
51
|
+
if (original.receipt.integrityCheck === 'PENDING')
|
|
52
|
+
return original;
|
|
53
|
+
let op = original;
|
|
54
|
+
const update = async (patch) => op = await options.store.saveAuthority.advanceWorker(op, patch);
|
|
55
|
+
if (['ACCEPTED', 'SAVING'].includes(op.receipt.state) || (op.receipt.state === 'FAILED' && op.receipt.retryable && pendingCode(op))) {
|
|
56
|
+
const code = pendingCode(op);
|
|
57
|
+
await update({ state: 'RECOVERY_REQUIRED', stage: 'RECONCILING', safeRetry: false, retryable: true, error: code ? diagnostic(code, true, true) : null, ...(code ? { mayAlreadyBeSaved: true } : {}) });
|
|
58
|
+
}
|
|
59
|
+
if (op.receipt.state === 'SAVED' || op.receipt.state === 'BLOCKED')
|
|
60
|
+
return op;
|
|
61
|
+
try {
|
|
62
|
+
const authority = await options.store.saveAuthority.read();
|
|
63
|
+
const run = (await options.store.runs.read()).runs.find(r => r.runId === op.receipt.runId);
|
|
64
|
+
const digest = authority.connections.find(c => c.connectionId === op.connectionId)?.bindingDigestRef;
|
|
65
|
+
if (!run || run.binding !== digest || run.job?.jobId !== op.receipt.jobId)
|
|
66
|
+
throw new StudioError('SAVE_BINDING_MISMATCH', 403);
|
|
67
|
+
if (!op.transfer?.completed) {
|
|
68
|
+
await reconcilePartialSaveFile(options.outputDir, op);
|
|
69
|
+
return update({ safeRetry: true, retryable: true, mayAlreadyBeSaved: false, error: op.receipt.state === 'FAILED' && op.receipt.error ? { ...op.receipt.error, mayAlreadyBeSaved: false } : null, facts: { file: 'ABSENT', registration: 'ABSENT', relation: 'ABSENT' } });
|
|
70
|
+
}
|
|
71
|
+
const evidence = await reconcilePublishedSaveFile(options.outputDir, op, limit);
|
|
72
|
+
// Ownership revalidation permits this SAME operation's local reconciliation,
|
|
73
|
+
// not a fresh download or an integrity VERIFIED claim.
|
|
74
|
+
const pending = pendingCode(op);
|
|
75
|
+
await update({ safeRetry: true, retryable: true, mayAlreadyBeSaved: true, error: pending ? diagnostic(pending, true, true) : null });
|
|
76
|
+
if (op.receipt.integrityCheck === 'PENDING')
|
|
77
|
+
return op;
|
|
78
|
+
await update({ state: 'SAVING', stage: 'RECONCILING', attempt: op.receipt.attempt + 1, safeRetry: false, error: null });
|
|
79
|
+
const asset = { assetId: op.assetId, displayName: path.basename(evidence.completed.relativePath), ...evidence.completed, savedAt: op.receipt.createdAt };
|
|
80
|
+
await update({ stage: 'FILE_READY', facts: { file: 'NOT_CHECKED', registration: op.receipt.facts.registration, relation: op.receipt.facts.relation }, mayAlreadyBeSaved: true });
|
|
81
|
+
await options.hooks?.beforeCatalogCommit?.();
|
|
82
|
+
await options.store.registerExistingManagedFile(asset);
|
|
83
|
+
await update({ stage: 'REGISTERED', asset: { ...assetReceipt(asset), absolutePath: evidence.absolutePath }, facts: { file: 'NOT_CHECKED', registration: 'PRESENT', relation: 'ABSENT' } });
|
|
84
|
+
await options.hooks?.afterCatalogBeforeRelation?.();
|
|
85
|
+
await update({ state: 'SAVED', stage: 'LINKED', facts: { file: 'NOT_CHECKED', registration: 'PRESENT', relation: 'PRESENT' }, checkedAt: null, error: null, safeRetry: false, retryable: false, mayAlreadyBeSaved: true });
|
|
86
|
+
await options.hooks?.afterRelationBeforeResponse?.();
|
|
87
|
+
return op;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof SaveIntegrityPending) {
|
|
91
|
+
const current = (await options.store.saveAuthority.read()).operations.find(o => o.receipt.operationId === op.receipt.operationId);
|
|
92
|
+
if (!current)
|
|
93
|
+
throw new StudioError('SAVE_OPERATION_NOT_FOUND', 404);
|
|
94
|
+
return current;
|
|
95
|
+
}
|
|
96
|
+
if (error instanceof StudioError && error.code === 'SAVE_REVISION_CONFLICT')
|
|
97
|
+
throw error;
|
|
98
|
+
const latest = (await options.store.saveAuthority.read()).operations.find(o => o.receipt.operationId === op.receipt.operationId);
|
|
99
|
+
if (!latest)
|
|
100
|
+
throw new StudioError('SAVE_OPERATION_NOT_FOUND', 404);
|
|
101
|
+
op = latest;
|
|
102
|
+
if (op.receipt.state === 'SAVED')
|
|
103
|
+
return op;
|
|
104
|
+
const classified = await recoveryFailure(op, workerCode(error instanceof StudioError ? error.code : 'SAVE_WRITE_FAILED'));
|
|
105
|
+
const code = classified.code;
|
|
106
|
+
const unsafe = ['SAVE_PATH_UNSAFE', 'SAVE_TARGET_CONFLICT', 'SAVE_BINDING_MISMATCH'].includes(code);
|
|
107
|
+
const retryable = !unsafe;
|
|
108
|
+
await update({ state: unsafe ? 'BLOCKED' : op.receipt.state === 'SAVING' ? 'RECOVERY_REQUIRED' : op.receipt.state, facts: { ...op.receipt.facts, file: unsafe ? 'UNSAFE' : classified.safeRetry ? 'NOT_CHECKED' : 'UNKNOWN' }, safeRetry: classified.safeRetry, retryable, mayAlreadyBeSaved: true, error: diagnostic(code, retryable, true) });
|
|
109
|
+
throw error instanceof StudioError ? error : new StudioError('SAVE_WRITE_FAILED', 409);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function serialize(action) {
|
|
113
|
+
const next = queue.catch(() => { }).then(action);
|
|
114
|
+
queue = next.then(() => { }, () => { });
|
|
115
|
+
return next;
|
|
116
|
+
}
|
|
117
|
+
async function containFailure(id) {
|
|
118
|
+
// An unavailable journal cannot always be amended. Preserve a safe idle()
|
|
119
|
+
// diagnostic and make a best-effort durable checkpoint once it is readable.
|
|
120
|
+
failures.push(new StudioError('SAVE_AUTHORITY_UNAVAILABLE', 503));
|
|
121
|
+
try {
|
|
122
|
+
const op = (await options.store.saveAuthority.read()).operations.find(o => o.receipt.operationId === id);
|
|
123
|
+
if (!op || !['ACCEPTED', 'SAVING'].includes(op.receipt.state))
|
|
124
|
+
return;
|
|
125
|
+
await options.store.saveAuthority.advanceWorker(op, {
|
|
126
|
+
state: 'RECOVERY_REQUIRED', safeRetry: false, retryable: true, mayAlreadyBeSaved: null,
|
|
127
|
+
error: { ...cloudDiagnostic(new StudioError('SAVE_AUTHORITY_UNAVAILABLE', 503)), retryable: true, mayAlreadyBeSaved: null },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch { /* No fabricated receipt when the authority remains unavailable. */ }
|
|
131
|
+
}
|
|
132
|
+
async function execute(id) {
|
|
133
|
+
let op = (await options.store.saveAuthority.read()).operations.find(o => o.receipt.operationId === id);
|
|
134
|
+
if (!op || !(op.receipt.state === 'ACCEPTED' || (op.receipt.state === 'SAVING' && op.receipt.stage === 'RECONCILING')) || closed)
|
|
135
|
+
return;
|
|
136
|
+
progress.delete(id);
|
|
137
|
+
if (suspended.has(op.connectionId)) {
|
|
138
|
+
await options.store.saveAuthority.advanceWorker(op, { state: 'RECOVERY_REQUIRED', safeRetry: false, retryable: true });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const update = async (patch, transfer) => {
|
|
142
|
+
op = await options.store.saveAuthority.advanceWorker(op, patch, transfer);
|
|
143
|
+
return op;
|
|
144
|
+
};
|
|
145
|
+
let published = false;
|
|
146
|
+
let timedOut = false;
|
|
147
|
+
let ownedTemporary = false;
|
|
148
|
+
let transportFailed = false;
|
|
149
|
+
activeConnection = op.connectionId;
|
|
150
|
+
controller = new AbortController();
|
|
151
|
+
const signal = controller.signal;
|
|
152
|
+
const expire = () => { timedOut = true; controller?.abort(); };
|
|
153
|
+
const cancelDeadline = options.armDeadline ? options.armDeadline(120_000, expire) : (() => { const timer = setTimeout(expire, 120_000); timer.unref(); return () => clearTimeout(timer); })();
|
|
154
|
+
try {
|
|
155
|
+
await options.hooks?.afterAcceptedCommit?.();
|
|
156
|
+
signal.throwIfAborted();
|
|
157
|
+
const snapshot = await options.store.saveAuthority.read();
|
|
158
|
+
const connection = snapshot.connections.find(c => c.connectionId === op.connectionId);
|
|
159
|
+
const run = (await options.store.runs.read()).runs.find(r => r.runId === op.receipt.runId);
|
|
160
|
+
if (!run || run.binding !== connection?.bindingDigestRef || run.job?.jobId !== op.receipt.jobId)
|
|
161
|
+
throw new StudioError('SAVE_BINDING_MISMATCH', 403);
|
|
162
|
+
// A persisted URL is only historical evidence. Renew through the same
|
|
163
|
+
// authenticated job identity before any asset transport is allowed.
|
|
164
|
+
let rawJob;
|
|
165
|
+
let secret;
|
|
166
|
+
if (options.refreshJob)
|
|
167
|
+
rawJob = await options.refreshJob(structuredClone(op), signal);
|
|
168
|
+
else {
|
|
169
|
+
if (!options.loadConfig)
|
|
170
|
+
throw new StudioError('SAVE_CLOUD_CONFIG_REQUIRED', 409);
|
|
171
|
+
const config = { ...await options.loadConfig() };
|
|
172
|
+
secret = config.apiKey?.trim();
|
|
173
|
+
if (!secret || studioBindingDigest(new URL(config.baseUrl).origin, secret) !== connection.bindingDigestRef)
|
|
174
|
+
throw new StudioError('SAVE_BINDING_MISMATCH', 403);
|
|
175
|
+
config.apiKey = secret;
|
|
176
|
+
const client = new LumenKeyClient(config);
|
|
177
|
+
client.requireKey();
|
|
178
|
+
rawJob = await client.getJob(op.receipt.jobId, { signal });
|
|
179
|
+
}
|
|
180
|
+
signal.throwIfAborted();
|
|
181
|
+
const fresh = projectJob(rawJob, secret ?? '', run.job, true);
|
|
182
|
+
if (fresh.jobId !== op.receipt.jobId)
|
|
183
|
+
throw new StudioError('SAVE_OUTPUT_IDENTITY_CONFLICT', 409);
|
|
184
|
+
const view = projectOutputViews({ ...run, job: fresh }, { rootReady: true, authorityHealthy: true }).find(v => v.localOutputId === op.receipt.localOutputId && v.gatewayAssetId?.toLowerCase() === op.receipt.gatewayAssetId.toLowerCase());
|
|
185
|
+
if (!view?.saveAllowed)
|
|
186
|
+
throw new StudioError('SAVE_SOURCE_UNAVAILABLE', 409);
|
|
187
|
+
const source = fresh.outputs.find(o => o.assetId?.toLowerCase() === op.receipt.gatewayAssetId.toLowerCase());
|
|
188
|
+
if (!source)
|
|
189
|
+
throw new StudioError('SAVE_SOURCE_UNAVAILABLE', 409);
|
|
190
|
+
const url = new URL(source.url);
|
|
191
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
192
|
+
throw new StudioError('SAVE_MEDIA_UNSUPPORTED', 415);
|
|
193
|
+
const root = await prepareStudioRoot(options.outputDir);
|
|
194
|
+
if (root.assetsDir !== snapshot.canonicalAssetsPath)
|
|
195
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
196
|
+
const attempt = op.receipt.state === 'SAVING' ? op.receipt.attempt : op.receipt.attempt + 1;
|
|
197
|
+
const transfer = { temporaryName: `${op.assetId}.${attempt}.part` };
|
|
198
|
+
await update({ state: 'SAVING', stage: 'DOWNLOADING', attempt, facts: { file: 'ABSENT', registration: 'ABSENT', relation: 'ABSENT' } }, transfer);
|
|
199
|
+
await assertStudioRoot(root);
|
|
200
|
+
signal.throwIfAborted();
|
|
201
|
+
const handle = await open(path.join(root.assetsDir, transfer.temporaryName), 'wx', 0o600);
|
|
202
|
+
ownedTemporary = true;
|
|
203
|
+
let result;
|
|
204
|
+
let declared = '';
|
|
205
|
+
try {
|
|
206
|
+
const owned = await handle.stat();
|
|
207
|
+
transfer.owner = { dev: String(owned.dev), ino: String(owned.ino), birthtimeMs: owned.birthtimeMs };
|
|
208
|
+
await update({}, transfer); // durable exclusive-open proof precedes network
|
|
209
|
+
const fetched = await (options.fetchAsset ?? assetFetch)(url.href, { redirect: 'error', signal }).catch(error => { transportFailed = true; throw error; });
|
|
210
|
+
try {
|
|
211
|
+
if (!fetched.response.ok || !fetched.response.body)
|
|
212
|
+
throw new StudioError('SAVE_SOURCE_UNAVAILABLE', 409);
|
|
213
|
+
declared = (fetched.response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
214
|
+
const rawLength = fetched.response.headers.get('content-length');
|
|
215
|
+
if (rawLength !== null && !/^\d+$/.test(rawLength))
|
|
216
|
+
throw new StudioError('SAVE_LENGTH_MISMATCH', 422);
|
|
217
|
+
result = await streamAssetBodyToFile(fetched.response.body, handle, { limit, signal, requireNonEmpty: true, onReadError: () => { transportFailed = true; }, ...(rawLength !== null ? { expectedBytes: Number(rawLength) } : {}), onProgress: async (receivedBytes) => {
|
|
218
|
+
progress.set(id, { revision: op.receipt.revision, attempt: op.receipt.attempt, receivedBytes, totalBytes: rawLength !== null && Number(rawLength) > 0 ? Number(rawLength) : null });
|
|
219
|
+
await options.hooks?.duringDownload?.(receivedBytes);
|
|
220
|
+
} });
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await fetched.dispose();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
await handle.close();
|
|
228
|
+
}
|
|
229
|
+
let mediaType;
|
|
230
|
+
try {
|
|
231
|
+
mediaType = detectStudioMediaType(result.prefix);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
throw new StudioError('SAVE_MEDIA_UNSUPPORTED', 415);
|
|
235
|
+
}
|
|
236
|
+
if ((declared && declared !== 'application/octet-stream' && declared !== mediaType) || source.type !== mediaType.split('/')[0])
|
|
237
|
+
throw new StudioError('SAVE_MEDIA_UNSUPPORTED', 415);
|
|
238
|
+
const relativePath = `assets/${op.assetId}.${mediaExtensions[mediaType]}`;
|
|
239
|
+
const asset = { assetId: op.assetId, displayName: `${op.assetId}.${mediaExtensions[mediaType]}`, mediaType, relativePath, byteLength: result.byteLength, sha256: result.sha256, savedAt: op.receipt.createdAt };
|
|
240
|
+
transfer.completed = { mediaType, relativePath, byteLength: result.byteLength, sha256: result.sha256 };
|
|
241
|
+
await update({ facts: { file: 'PARTIAL', registration: 'ABSENT', relation: 'ABSENT' } }, transfer);
|
|
242
|
+
await assertStudioRoot(root);
|
|
243
|
+
signal.throwIfAborted();
|
|
244
|
+
const evidence = await reconcilePublishedSaveFile(options.outputDir, op, limit, async () => {
|
|
245
|
+
published = true;
|
|
246
|
+
await options.hooks?.afterLinkBeforeUnlink?.();
|
|
247
|
+
});
|
|
248
|
+
const absolutePath = evidence.absolutePath;
|
|
249
|
+
published = true;
|
|
250
|
+
await options.hooks?.afterFilePublishBeforeCheckpoint?.();
|
|
251
|
+
await update({ stage: 'FILE_READY', facts: { file: 'NOT_CHECKED', registration: 'ABSENT', relation: 'ABSENT' }, mayAlreadyBeSaved: true });
|
|
252
|
+
await options.hooks?.beforeCatalogCommit?.();
|
|
253
|
+
await options.store.registerExistingManagedFile(asset);
|
|
254
|
+
await update({ stage: 'REGISTERED', asset: { ...assetReceipt(asset), absolutePath }, facts: { file: 'NOT_CHECKED', registration: 'PRESENT', relation: 'ABSENT' } });
|
|
255
|
+
await options.hooks?.afterCatalogBeforeRelation?.();
|
|
256
|
+
await update({ state: 'SAVED', stage: 'LINKED', facts: { file: 'NOT_CHECKED', registration: 'PRESENT', relation: 'PRESENT' }, error: null, safeRetry: false, retryable: false, mayAlreadyBeSaved: true, checkedAt: null });
|
|
257
|
+
await options.hooks?.afterRelationBeforeResponse?.();
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
const latest = (await options.store.saveAuthority.read()).operations.find(o => o.receipt.operationId === id);
|
|
261
|
+
if (!latest || latest.receipt.state === 'SAVED')
|
|
262
|
+
return;
|
|
263
|
+
op = latest;
|
|
264
|
+
const conflict = hasFsCode(error, 'EEXIST');
|
|
265
|
+
const initialCode = timedOut ? 'SAVE_DOWNLOAD_TIMEOUT' : conflict ? 'SAVE_TARGET_CONFLICT' : error instanceof StudioError ? error.code : error instanceof LumenKeyError && error.code === 'DOWNLOAD_TOO_LARGE' ? 'SAVE_MEDIA_TOO_LARGE' : error instanceof LumenKeyError && ['DOWNLOAD_LENGTH_MISMATCH', 'DOWNLOAD_EMPTY'].includes(error.code) ? 'SAVE_LENGTH_MISMATCH' : signal.aborted || transportFailed ? 'SAVE_NETWORK_ERROR' : 'SAVE_WRITE_FAILED';
|
|
266
|
+
const classified = await recoveryFailure(op, workerCode(initialCode), published);
|
|
267
|
+
const code = classified.code;
|
|
268
|
+
const status = code === 'SAVE_MEDIA_TOO_LARGE' ? 413 : code === 'SAVE_LENGTH_MISMATCH' ? 422 : code === 'SAVE_DOWNLOAD_TIMEOUT' || code === 'SAVE_NETWORK_ERROR' ? 503 : error instanceof StudioError ? error.status : 409;
|
|
269
|
+
const diagnostic = cloudDiagnostic(new StudioError(code, status));
|
|
270
|
+
const retryable = !conflict && !['SAVE_MEDIA_TOO_LARGE', 'SAVE_MEDIA_UNSUPPORTED', 'SAVE_LENGTH_MISMATCH', 'SAVE_PATH_UNSAFE', 'SAVE_OUTPUT_IDENTITY_CONFLICT', 'SAVE_BINDING_MISMATCH'].includes(code);
|
|
271
|
+
await update({ state: conflict || ['SAVE_PATH_UNSAFE', 'SAVE_TARGET_CONFLICT'].includes(code) ? 'BLOCKED' : published || signal.aborted ? 'RECOVERY_REQUIRED' : 'FAILED', error: { ...diagnostic, retryable, mayAlreadyBeSaved: published ? true : ownedTemporary ? null : false }, retryable, safeRetry: classified.safeRetry, mayAlreadyBeSaved: published ? true : ownedTemporary ? null : false });
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
cancelDeadline();
|
|
275
|
+
controller = undefined;
|
|
276
|
+
activeConnection = undefined;
|
|
277
|
+
progress.delete(id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const worker = {
|
|
281
|
+
progress(id, revision, attempt) { const p = progress.get(id); return p && p.revision === revision && p.attempt === attempt ? { receivedBytes: p.receivedBytes, totalBytes: p.totalBytes } : null; },
|
|
282
|
+
suspend(connectionId) { suspended.add(connectionId); if (activeConnection === connectionId)
|
|
283
|
+
controller?.abort(); },
|
|
284
|
+
activate(connectionId) { if (!closed)
|
|
285
|
+
suspended.delete(connectionId); },
|
|
286
|
+
enqueue(id) {
|
|
287
|
+
if (closed || scheduled.has(id))
|
|
288
|
+
return;
|
|
289
|
+
scheduled.add(id);
|
|
290
|
+
queue = queue.catch(() => { }).then(() => execute(id)).catch(() => containFailure(id)).finally(() => { scheduled.delete(id); });
|
|
291
|
+
},
|
|
292
|
+
async idle() { await queue; const error = failures.shift(); if (error)
|
|
293
|
+
throw error; },
|
|
294
|
+
close() {
|
|
295
|
+
return closePromise ??= (async () => {
|
|
296
|
+
closed = true;
|
|
297
|
+
controller?.abort();
|
|
298
|
+
await queue;
|
|
299
|
+
const snapshot = await options.store.saveAuthority.read();
|
|
300
|
+
for (const op of snapshot.operations)
|
|
301
|
+
if (['ACCEPTED', 'SAVING'].includes(op.receipt.state)) {
|
|
302
|
+
await options.store.saveAuthority.advanceWorker(op, {
|
|
303
|
+
state: 'RECOVERY_REQUIRED', safeRetry: false, retryable: true, mayAlreadyBeSaved: null,
|
|
304
|
+
error: { ...cloudDiagnostic(new StudioError('SAVE_NETWORK_ERROR', 503)), retryable: true, mayAlreadyBeSaved: null },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
})();
|
|
308
|
+
},
|
|
309
|
+
recover: () => serialize(async () => {
|
|
310
|
+
const snapshot = await options.store.saveAuthority.read();
|
|
311
|
+
for (const op of snapshot.operations)
|
|
312
|
+
if (['ACCEPTED', 'SAVING', 'RECOVERY_REQUIRED'].includes(op.receipt.state) || (op.receipt.state === 'FAILED' && op.receipt.retryable && ['SAVE_WRITE_FAILED', 'SAVE_NETWORK_ERROR', 'SAVE_DOWNLOAD_TIMEOUT', 'SAVE_REGISTRATION_PENDING', 'SAVE_RELATION_PENDING'].includes(op.receipt.error?.code ?? ''))) {
|
|
313
|
+
try {
|
|
314
|
+
await localRecovery(op);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (!(error instanceof StudioError) || ['SAVE_AUTHORITY_INVALID', 'SAVE_AUTHORITY_UNAVAILABLE', 'STUDIO_JOURNAL_INVALID', 'STUDIO_CATALOG_INVALID', 'STUDIO_STORAGE_CLOSED', 'STUDIO_STORAGE_BUSY', 'STUDIO_STORAGE_FAILED', 'SAVE_REVISION_CONFLICT'].includes(error.code))
|
|
318
|
+
throw error;
|
|
319
|
+
const current = (await options.store.saveAuthority.read()).operations.find(o => o.receipt.operationId === op.receipt.operationId);
|
|
320
|
+
const pending = current && ['SAVE_REGISTRATION_PENDING', 'SAVE_RELATION_PENDING'].includes(current.receipt.error?.code ?? '') && workerCode(error.code) === 'SAVE_WRITE_FAILED';
|
|
321
|
+
if (!current || (!pending && current.receipt.error?.code !== workerCode(error.code)) || !['FAILED', 'RECOVERY_REQUIRED', 'BLOCKED'].includes(current.receipt.state))
|
|
322
|
+
throw error;
|
|
323
|
+
if (!pending)
|
|
324
|
+
failures.push(error); // pending already has its classified durable diagnostic
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}),
|
|
328
|
+
retry: (id, connectionId, revision) => serialize(async () => {
|
|
329
|
+
const op = (await options.store.saveAuthority.read()).operations.find(o => o.receipt.operationId === id);
|
|
330
|
+
if (!op)
|
|
331
|
+
throw new StudioError('SAVE_OPERATION_NOT_FOUND', 404);
|
|
332
|
+
if (op.connectionId !== connectionId)
|
|
333
|
+
throw new StudioError('SAVE_BINDING_MISMATCH', 403);
|
|
334
|
+
if (op.receipt.revision !== revision)
|
|
335
|
+
throw Object.assign(new StudioError('SAVE_REVISION_CONFLICT', 409), { operation: op });
|
|
336
|
+
if (!['FAILED', 'RECOVERY_REQUIRED'].includes(op.receipt.state))
|
|
337
|
+
throw new StudioError('SAVE_RETRY_NOT_ALLOWED', 409);
|
|
338
|
+
if (closed || suspended.has(connectionId))
|
|
339
|
+
throw new StudioError('SAVE_BINDING_REQUIRED', 403);
|
|
340
|
+
if (!op.transfer?.completed) {
|
|
341
|
+
if (closed)
|
|
342
|
+
throw new StudioError('STUDIO_STORAGE_CLOSED', 409);
|
|
343
|
+
const checked = await localRecovery(op);
|
|
344
|
+
if (!checked.receipt.safeRetry)
|
|
345
|
+
throw new StudioError('SAVE_RETRY_NOT_ALLOWED', 409);
|
|
346
|
+
const claimed = await options.store.saveAuthority.update(id, connectionId, checked.receipt.revision, { state: 'SAVING', stage: 'RECONCILING', attempt: checked.receipt.attempt + 1, safeRetry: false, error: null });
|
|
347
|
+
worker.enqueue(id);
|
|
348
|
+
return claimed;
|
|
349
|
+
}
|
|
350
|
+
return localRecovery(op);
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
workers.set(options.store, worker);
|
|
354
|
+
return worker;
|
|
355
|
+
}
|
|
356
|
+
function assetReceipt(asset) {
|
|
357
|
+
const { savedAt: _savedAt, ...rest } = asset;
|
|
358
|
+
return { ...rest, contentUrl: `/studio/api/assets/${asset.assetId}/content` };
|
|
359
|
+
}
|
|
360
|
+
//# sourceMappingURL=output-save-worker.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { StudioAsset, StudioRoot } from './types.js';
|
|
2
|
+
export declare function prepareStudioRoot(outputDir: string): Promise<StudioRoot>;
|
|
3
|
+
export declare function assertStudioRoot(root: StudioRoot): Promise<void>;
|
|
4
|
+
export declare function assertRegularFile(file: string): Promise<void>;
|
|
5
|
+
export declare function resolveStudioAssetPath(root: StudioRoot, asset: StudioAsset): Promise<string>;
|
|
6
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { lstat, mkdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { hasFsCode, storageError, StudioError } from './errors.js';
|
|
4
|
+
import { mediaExtensions, validateStudioAssetId } from './media.js';
|
|
5
|
+
async function directoryChain(absolute, create) {
|
|
6
|
+
const parsed = path.parse(absolute);
|
|
7
|
+
let current = parsed.root;
|
|
8
|
+
for (const segment of absolute.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
9
|
+
current = path.join(current, segment);
|
|
10
|
+
if (create) {
|
|
11
|
+
try {
|
|
12
|
+
await mkdir(current);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (!hasFsCode(error, 'EEXIST'))
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const info = await lstat(current);
|
|
20
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
21
|
+
throw new StudioError('STUDIO_PATH_UNSAFE', 409);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function prepareStudioRoot(outputDir) {
|
|
25
|
+
if (typeof outputDir !== 'string' || !path.isAbsolute(outputDir) || /[\u0000-\u001f]/.test(outputDir))
|
|
26
|
+
throw new StudioError('STUDIO_OUTPUT_ROOT_REQUIRED');
|
|
27
|
+
try {
|
|
28
|
+
const absolute = path.resolve(outputDir);
|
|
29
|
+
await directoryChain(absolute, true);
|
|
30
|
+
const canonicalOutputDir = await realpath(absolute);
|
|
31
|
+
const root = { canonicalOutputDir, managedDir: path.join(canonicalOutputDir, 'LumenKey'), assetsDir: path.join(canonicalOutputDir, 'LumenKey', 'assets') };
|
|
32
|
+
await directoryChain(root.assetsDir, true);
|
|
33
|
+
await assertStudioRoot(root);
|
|
34
|
+
return root;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw storageError(error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function assertStudioRoot(root) {
|
|
41
|
+
try {
|
|
42
|
+
await directoryChain(root.assetsDir, false);
|
|
43
|
+
for (const p of [root.canonicalOutputDir, root.managedDir, root.assetsDir]) {
|
|
44
|
+
if (await realpath(p) !== p)
|
|
45
|
+
throw new StudioError('STUDIO_PATH_UNSAFE', 409);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error instanceof StudioError)
|
|
50
|
+
throw error;
|
|
51
|
+
throw new StudioError('STUDIO_PATH_UNSAFE', 409);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function assertRegularFile(file) {
|
|
55
|
+
const info = await lstat(file);
|
|
56
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)
|
|
57
|
+
throw new StudioError('STUDIO_PATH_UNSAFE', 409);
|
|
58
|
+
}
|
|
59
|
+
export async function resolveStudioAssetPath(root, asset) {
|
|
60
|
+
await assertStudioRoot(root);
|
|
61
|
+
validateStudioAssetId(asset.assetId);
|
|
62
|
+
if (asset.relativePath !== `assets/${asset.assetId}.${mediaExtensions[asset.mediaType]}`)
|
|
63
|
+
throw new StudioError('STUDIO_PATH_UNSAFE', 409);
|
|
64
|
+
const file = path.join(root.managedDir, asset.relativePath);
|
|
65
|
+
await assertRegularFile(file);
|
|
66
|
+
return file;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=paths.js.map
|