@transloadit/node 6.3.0 → 6.5.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 +30 -5
- package/dist/Transloadit.d.ts.map +1 -1
- package/dist/Transloadit.js +61 -5
- package/dist/Transloadit.js.map +1 -1
- package/dist/alphalib/types/builtinTemplates.d.ts +22 -3
- package/dist/alphalib/types/builtinTemplates.d.ts.map +1 -1
- package/dist/alphalib/types/builtinTemplates.js +39 -3
- package/dist/alphalib/types/builtinTemplates.js.map +1 -1
- package/dist/alphalib/types/template.d.ts +18 -0
- package/dist/alphalib/types/template.d.ts.map +1 -1
- package/dist/alphalib/types/template.js +19 -0
- package/dist/alphalib/types/template.js.map +1 -1
- package/dist/cli/OutputCtl.d.ts +1 -0
- package/dist/cli/OutputCtl.d.ts.map +1 -1
- package/dist/cli/OutputCtl.js +1 -1
- package/dist/cli/OutputCtl.js.map +1 -1
- package/dist/cli/commands/BaseCommand.d.ts +3 -2
- package/dist/cli/commands/BaseCommand.d.ts.map +1 -1
- package/dist/cli/commands/BaseCommand.js +27 -3
- package/dist/cli/commands/BaseCommand.js.map +1 -1
- package/dist/cli/commands/assemblies.d.ts +21 -1
- package/dist/cli/commands/assemblies.d.ts.map +1 -1
- package/dist/cli/commands/assemblies.js +584 -91
- package/dist/cli/commands/assemblies.js.map +1 -1
- package/dist/cli/commands/templates.d.ts +1 -1
- package/dist/cli/commands/templates.d.ts.map +1 -1
- package/dist/cli/commands/templates.js +57 -67
- package/dist/cli/commands/templates.js.map +1 -1
- package/dist/cli/errors.d.ts +8 -0
- package/dist/cli/errors.d.ts.map +1 -0
- package/dist/cli/errors.js +28 -0
- package/dist/cli/errors.js.map +1 -0
- package/dist/cli/helpers.d.ts.map +1 -1
- package/dist/cli/helpers.js +22 -0
- package/dist/cli/helpers.js.map +1 -1
- package/dist/cli/intentRuntime.d.ts +1 -0
- package/dist/cli/intentRuntime.d.ts.map +1 -1
- package/dist/cli/intentRuntime.js +9 -3
- package/dist/cli/intentRuntime.js.map +1 -1
- package/dist/cli/resultFiles.d.ts +1 -0
- package/dist/cli/resultFiles.d.ts.map +1 -1
- package/dist/cli/resultFiles.js.map +1 -1
- package/dist/cli/resultUrls.d.ts +2 -1
- package/dist/cli/resultUrls.d.ts.map +1 -1
- package/dist/cli/resultUrls.js +6 -3
- package/dist/cli/resultUrls.js.map +1 -1
- package/dist/cli/stepsInput.d.ts.map +1 -1
- package/dist/cli/stepsInput.js +17 -4
- package/dist/cli/stepsInput.js.map +1 -1
- package/dist/cli/types.d.ts +66 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/types.js +29 -0
- package/dist/cli/types.js.map +1 -1
- package/dist/tus.d.ts +7 -2
- package/dist/tus.d.ts.map +1 -1
- package/dist/tus.js +58 -4
- package/dist/tus.js.map +1 -1
- package/package.json +1 -1
- package/src/Transloadit.ts +65 -4
- package/src/alphalib/types/builtinTemplates.ts +50 -3
- package/src/alphalib/types/template.ts +30 -0
- package/src/cli/OutputCtl.ts +2 -1
- package/src/cli/commands/BaseCommand.ts +29 -7
- package/src/cli/commands/assemblies.ts +775 -118
- package/src/cli/commands/templates.ts +93 -98
- package/src/cli/errors.ts +34 -0
- package/src/cli/helpers.ts +21 -0
- package/src/cli/intentRuntime.ts +10 -3
- package/src/cli/resultFiles.ts +1 -0
- package/src/cli/resultUrls.ts +11 -3
- package/src/cli/stepsInput.ts +19 -4
- package/src/cli/types.ts +35 -0
- package/src/tus.ts +72 -5
|
@@ -9,13 +9,15 @@ import { pipeline } from 'node:stream/promises';
|
|
|
9
9
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
10
10
|
import { promisify } from 'node:util';
|
|
11
11
|
import { Command, Option } from 'clipanion';
|
|
12
|
-
import got from 'got';
|
|
12
|
+
import got, { RequestError } from 'got';
|
|
13
13
|
import PQueue from 'p-queue';
|
|
14
14
|
import * as t from 'typanion';
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { formatLintIssue } from "../../alphalib/assembly-linter.lang.en.js";
|
|
17
17
|
import { tryCatch } from "../../alphalib/tryCatch.js";
|
|
18
|
+
import { assemblyStatusResultSchema } from "../../alphalib/types/assemblyStatus.js";
|
|
18
19
|
import { ensureUniqueCounterValue } from "../../ensureUniqueCounter.js";
|
|
20
|
+
import { prepareInputFiles } from "../../inputFiles.js";
|
|
19
21
|
import { lintAssemblyInstructions } from "../../lintAssemblyInstructions.js";
|
|
20
22
|
import { lintingExamples } from "../docs/assemblyLintingExamples.js";
|
|
21
23
|
import { concurrencyOption, deleteAfterProcessingOption, inputPathsOption, printUrlsOption, recursiveOption, reprocessStaleOption, singleAssemblyOption, validateSharedFileProcessingOptions, watchOption, } from "../fileProcessingOptions.js";
|
|
@@ -23,7 +25,7 @@ import { formatAPIError, readCliInput } from "../helpers.js";
|
|
|
23
25
|
import { normalizeAssemblyResults } from "../resultFiles.js";
|
|
24
26
|
import { collectNormalizedResultUrlRows, printResultUrls } from "../resultUrls.js";
|
|
25
27
|
import { parseStepsInputJson, readStepsInputFile } from "../stepsInput.js";
|
|
26
|
-
import { ensureError, isErrnoException } from "../types.js";
|
|
28
|
+
import { assemblySummarySchema, ensureError, isErrnoException } from "../types.js";
|
|
27
29
|
import { AuthenticatedCommand, UnauthenticatedCommand } from "./BaseCommand.js";
|
|
28
30
|
function formatAssemblyReference({ assemblyId, assemblyUrl, }) {
|
|
29
31
|
if (assemblyUrl != null) {
|
|
@@ -95,11 +97,7 @@ export function list(output, client, { before, after, fields, keywords, pagesize
|
|
|
95
97
|
export async function get(output, client, { assemblies }) {
|
|
96
98
|
for (const assembly of assemblies) {
|
|
97
99
|
await delay(1000);
|
|
98
|
-
const
|
|
99
|
-
if (err) {
|
|
100
|
-
output.error(formatAPIError(err));
|
|
101
|
-
throw ensureError(err);
|
|
102
|
-
}
|
|
100
|
+
const result = await client.getAssembly(assembly);
|
|
103
101
|
output.print(result, result);
|
|
104
102
|
}
|
|
105
103
|
}
|
|
@@ -317,16 +315,32 @@ async function createExistingPathOutputPlan(outputPath) {
|
|
|
317
315
|
const [, stats] = await tryCatch(fsp.stat(outputPath));
|
|
318
316
|
return createOutputPlan(outputPath, stats?.mtime ?? new Date(0));
|
|
319
317
|
}
|
|
320
|
-
function dirProvider(output) {
|
|
321
|
-
|
|
318
|
+
function dirProvider(output, transientInputPaths) {
|
|
319
|
+
const namesByInput = new Map();
|
|
320
|
+
const usedNames = new Set();
|
|
321
|
+
return async (inpath, indir) => {
|
|
322
322
|
if (inpath == null) {
|
|
323
323
|
return await createExistingPathOutputPlan(output);
|
|
324
324
|
}
|
|
325
325
|
if (inpath === '-') {
|
|
326
326
|
throw new Error('You must provide an input to output to a directory');
|
|
327
327
|
}
|
|
328
|
-
|
|
329
|
-
relpath =
|
|
328
|
+
const inputKey = path.resolve(inpath);
|
|
329
|
+
let relpath = namesByInput.get(inputKey);
|
|
330
|
+
if (relpath == null) {
|
|
331
|
+
const requestedPath = transientInputPaths?.has(inputKey)
|
|
332
|
+
? path.basename(inpath)
|
|
333
|
+
: path.relative(indir ?? process.cwd(), inpath).replace(/^(\.\.\/)+/, '');
|
|
334
|
+
const parsed = path.parse(requestedPath);
|
|
335
|
+
relpath = await ensureUniqueCounterValue({
|
|
336
|
+
initialValue: requestedPath,
|
|
337
|
+
isTaken: (candidate) => usedNames.has(candidate),
|
|
338
|
+
reserve: (candidate) => usedNames.add(candidate),
|
|
339
|
+
nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`),
|
|
340
|
+
scope: usedNames,
|
|
341
|
+
});
|
|
342
|
+
namesByInput.set(inputKey, relpath);
|
|
343
|
+
}
|
|
330
344
|
const outpath = path.join(output, relpath);
|
|
331
345
|
return await createExistingPathOutputPlan(outpath);
|
|
332
346
|
};
|
|
@@ -342,7 +356,7 @@ function fileProvider(output) {
|
|
|
342
356
|
function nullProvider() {
|
|
343
357
|
return async (_inpath) => null;
|
|
344
358
|
}
|
|
345
|
-
async function downloadResultToFile(resultUrl, outPath, signal) {
|
|
359
|
+
async function downloadResultToFile(resultUrl, outPath, signal, isCurrent) {
|
|
346
360
|
await fsp.mkdir(path.dirname(outPath), { recursive: true });
|
|
347
361
|
const tempPath = path.join(path.dirname(outPath), `.${path.basename(outPath)}.${randomUUID()}.tmp`);
|
|
348
362
|
const outStream = fs.createWriteStream(tempPath);
|
|
@@ -352,6 +366,10 @@ async function downloadResultToFile(resultUrl, outPath, signal) {
|
|
|
352
366
|
await fsp.rm(tempPath, { force: true });
|
|
353
367
|
throw dlErr;
|
|
354
368
|
}
|
|
369
|
+
if (!isCurrent()) {
|
|
370
|
+
await fsp.rm(tempPath, { force: true });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
355
373
|
await fsp.rename(tempPath, outPath);
|
|
356
374
|
}
|
|
357
375
|
async function downloadResultToStdout(resultUrl, signal) {
|
|
@@ -386,45 +404,336 @@ async function downloadResultToStdout(resultUrl, signal) {
|
|
|
386
404
|
});
|
|
387
405
|
await pipeline(got.stream(resultUrl, { signal }), stdoutStream);
|
|
388
406
|
}
|
|
389
|
-
function sanitizeResultName(value) {
|
|
407
|
+
function sanitizeResultName(value, fallback) {
|
|
390
408
|
const base = path.basename(value);
|
|
391
|
-
|
|
409
|
+
const name = base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '');
|
|
410
|
+
if (name === '' || name === '.' || name === '..') {
|
|
411
|
+
if (fallback != null)
|
|
412
|
+
return sanitizeResultName(fallback);
|
|
413
|
+
throw new Error('Assembly result has an unsafe name');
|
|
414
|
+
}
|
|
415
|
+
return name;
|
|
392
416
|
}
|
|
393
|
-
async function
|
|
417
|
+
async function outputNameSensitivity(root) {
|
|
418
|
+
await fsp.mkdir(root, { recursive: true });
|
|
419
|
+
const probe = await fsp.mkdtemp(path.join(root, '.transloadit-names-'));
|
|
420
|
+
async function distinguishesNames(first, second) {
|
|
421
|
+
await fsp.writeFile(path.join(probe, first), '');
|
|
422
|
+
const [error] = await tryCatch(fsp.lstat(path.join(probe, second)));
|
|
423
|
+
if (error && (!isErrnoException(error) || error.code !== 'ENOENT'))
|
|
424
|
+
throw error;
|
|
425
|
+
return error != null;
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
// Assume this tree shares the root's naming rules; nested filesystems are tracked in #615.
|
|
429
|
+
return {
|
|
430
|
+
caseSensitive: await distinguishesNames('case', 'CASE'),
|
|
431
|
+
normalizationSensitive: await distinguishesNames('\u00e9', 'e\u0301'),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
await fsp.rm(probe, { recursive: true, force: true });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function downloadPathKey(target, reservedPaths) {
|
|
439
|
+
const absolutePath = path.resolve(target);
|
|
440
|
+
const folded = reservedPaths.caseSensitive ? absolutePath : absolutePath.toLowerCase();
|
|
441
|
+
return reservedPaths.normalizationSensitive ? folded : folded.normalize('NFC');
|
|
442
|
+
}
|
|
443
|
+
function hasReservedFileAncestor(target, reservedPaths) {
|
|
444
|
+
let current = downloadPathKey(target, reservedPaths);
|
|
445
|
+
while (true) {
|
|
446
|
+
if (reservedPaths.ownersByPath.has(current) && !reservedPaths.directories.has(current)) {
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
const parent = path.dirname(current);
|
|
450
|
+
if (parent === current)
|
|
451
|
+
return false;
|
|
452
|
+
current = parent;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function reserveDownloadPath(target, owner, reservedPaths, directory = false) {
|
|
456
|
+
const key = downloadPathKey(target, reservedPaths);
|
|
457
|
+
reservedPaths.ownersByPath.set(key, owner);
|
|
458
|
+
let current = directory ? key : path.dirname(key);
|
|
459
|
+
while (true) {
|
|
460
|
+
reservedPaths.directories.add(current);
|
|
461
|
+
const parent = path.dirname(current);
|
|
462
|
+
if (parent === current)
|
|
463
|
+
return;
|
|
464
|
+
current = parent;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async function ensureUniquePath(targetPath, reservedPaths, owner, directory = false, overwriteExistingFile = false) {
|
|
468
|
+
const selectedPath = reservedPaths.pathsByOwner.get(owner);
|
|
469
|
+
if (selectedPath != null)
|
|
470
|
+
return selectedPath;
|
|
394
471
|
const parsed = path.parse(targetPath);
|
|
395
472
|
return await ensureUniqueCounterValue({
|
|
396
473
|
initialValue: targetPath,
|
|
397
|
-
isTaken: async (candidate) =>
|
|
398
|
-
if (reservedPaths.has(candidate)) {
|
|
399
|
-
return true;
|
|
400
|
-
}
|
|
401
|
-
const [statErr] = await tryCatch(fsp.stat(candidate));
|
|
402
|
-
return statErr == null;
|
|
403
|
-
},
|
|
474
|
+
isTaken: async (candidate) => await isDownloadPathTaken(candidate, reservedPaths, owner, directory, overwriteExistingFile && candidate === targetPath),
|
|
404
475
|
reserve: (candidate) => {
|
|
405
|
-
|
|
476
|
+
reserveDownloadPath(candidate, owner, reservedPaths, directory);
|
|
477
|
+
reservedPaths.pathsByOwner.set(owner, candidate);
|
|
406
478
|
},
|
|
407
|
-
nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`),
|
|
479
|
+
nextValue: (counter) => path.join(parsed.dir, directory ? `${parsed.base}__${counter}` : `${parsed.name}__${counter}${parsed.ext}`),
|
|
408
480
|
scope: reservedPaths,
|
|
409
481
|
});
|
|
410
482
|
}
|
|
411
|
-
|
|
412
|
-
|
|
483
|
+
const resultPathSchema = z.object({
|
|
484
|
+
meta: z.object({ relative_path: z.string().nullish() }).nullish(),
|
|
485
|
+
});
|
|
486
|
+
function getResultRelativePath(file) {
|
|
487
|
+
const parsed = resultPathSchema.safeParse(file.file);
|
|
488
|
+
if (!parsed.success)
|
|
489
|
+
throw new Error('Assembly result has invalid relative path metadata');
|
|
490
|
+
const relativePath = parsed.data.meta?.relative_path;
|
|
491
|
+
if (relativePath == null)
|
|
492
|
+
return null;
|
|
493
|
+
const name = sanitizeResultName(file.name);
|
|
494
|
+
// Adaptive video results use an empty directory for the master playlist.
|
|
495
|
+
if (relativePath === '')
|
|
496
|
+
return name;
|
|
497
|
+
const normalizedPath = relativePath.replaceAll('\\', '/');
|
|
498
|
+
if (normalizedPath.includes('\u0000') ||
|
|
499
|
+
path.win32.isAbsolute(normalizedPath) ||
|
|
500
|
+
/^[a-z]:/i.test(normalizedPath) ||
|
|
501
|
+
normalizedPath.split('/').some((part) => part === '' || part === '.' || part === '..')) {
|
|
502
|
+
throw new Error('Assembly result has an unsafe relative path');
|
|
503
|
+
}
|
|
504
|
+
return path.join(normalizedPath, name);
|
|
505
|
+
}
|
|
506
|
+
async function downloadResultBundle(targets, signal, isCurrent, commits, onDownload) {
|
|
507
|
+
const stagingDirs = new Map();
|
|
508
|
+
const staged = [];
|
|
509
|
+
try {
|
|
510
|
+
for (const [index, target] of targets.entries()) {
|
|
511
|
+
if (!isCurrent())
|
|
512
|
+
return;
|
|
513
|
+
const parent = path.dirname(target.targetPath);
|
|
514
|
+
await fsp.mkdir(parent, { recursive: true });
|
|
515
|
+
let stagingDir = stagingDirs.get(parent);
|
|
516
|
+
if (stagingDir == null) {
|
|
517
|
+
// Stage beside each destination so publication also works across nested filesystems.
|
|
518
|
+
stagingDir = await fsp.mkdtemp(path.join(parent, '.transloadit-bundle-'));
|
|
519
|
+
stagingDirs.set(parent, stagingDir);
|
|
520
|
+
}
|
|
521
|
+
const stagingPath = path.join(stagingDir, String(index));
|
|
522
|
+
staged.push({ ...target, stagingPath });
|
|
523
|
+
onDownload();
|
|
524
|
+
await downloadResultToFile(target.resultUrl, stagingPath, signal, () => true);
|
|
525
|
+
}
|
|
526
|
+
await commits.add(async () => {
|
|
527
|
+
if (!isCurrent())
|
|
528
|
+
return;
|
|
529
|
+
// Finish publication once it starts, even if superseded. A failed newer job must not
|
|
530
|
+
// strand this playlist without its segments, and newer commits must wait for this one.
|
|
531
|
+
for (const { stagingPath, targetPath } of staged) {
|
|
532
|
+
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
|
533
|
+
await fsp.rename(stagingPath, targetPath);
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
await Promise.all([...stagingDirs.values()].map(async (dir) => await fsp.rm(dir, { recursive: true, force: true })));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
async function assertNoOutputSymlinks(root, target) {
|
|
542
|
+
// Reject preexisting descendant symlinks; another process can still race filesystem checks.
|
|
543
|
+
const relativePath = path.relative(root, target);
|
|
544
|
+
// The caller explicitly selected the root; only descendants come from result/input metadata.
|
|
545
|
+
if (relativePath === '')
|
|
546
|
+
return;
|
|
547
|
+
if (relativePath.startsWith(`..${path.sep}`) ||
|
|
548
|
+
relativePath === '..' ||
|
|
549
|
+
path.isAbsolute(relativePath)) {
|
|
550
|
+
throw new Error('Assembly result path escapes the output directory');
|
|
551
|
+
}
|
|
552
|
+
let current = root;
|
|
553
|
+
for (const part of relativePath.split(path.sep)) {
|
|
554
|
+
current = path.join(current, part);
|
|
555
|
+
const [error, stats] = await tryCatch(fsp.lstat(current));
|
|
556
|
+
if (error) {
|
|
557
|
+
if (isErrnoException(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR'))
|
|
558
|
+
return;
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
if (stats.isSymbolicLink()) {
|
|
562
|
+
throw new Error('Assembly result path contains a symbolic link');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async function isDownloadPathTaken(target, reservedPaths, owner, directory = false, overwriteExistingFile = false) {
|
|
567
|
+
const key = downloadPathKey(target, reservedPaths);
|
|
568
|
+
if (directory && reservedPaths.directories.has(key))
|
|
569
|
+
return false;
|
|
570
|
+
const existingOwner = reservedPaths.ownersByPath.get(key);
|
|
571
|
+
if (existingOwner != null)
|
|
572
|
+
return existingOwner !== owner;
|
|
573
|
+
if (reservedPaths.directories.has(key) || hasReservedFileAncestor(target, reservedPaths)) {
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
const [error, stats] = await tryCatch(fsp.lstat(target));
|
|
577
|
+
if (isErrnoException(error) && error.code === 'ENOTDIR')
|
|
578
|
+
return true;
|
|
579
|
+
if (error && (!isErrnoException(error) || error.code !== 'ENOENT'))
|
|
580
|
+
throw error;
|
|
581
|
+
if (error != null)
|
|
582
|
+
return false;
|
|
583
|
+
if (directory)
|
|
584
|
+
return !stats?.isDirectory();
|
|
585
|
+
return !overwriteExistingFile || !stats?.isFile();
|
|
586
|
+
}
|
|
587
|
+
async function prepareDownloadDirectory(root, target, reservedPaths) {
|
|
588
|
+
await assertNoOutputSymlinks(root, target);
|
|
589
|
+
if (hasReservedFileAncestor(target, reservedPaths)) {
|
|
590
|
+
throw new Error('Output directory conflicts with a result file');
|
|
591
|
+
}
|
|
592
|
+
await fsp.mkdir(target, { recursive: true });
|
|
413
593
|
}
|
|
414
594
|
const STALE_OUTPUT_GRACE_MS = 1000;
|
|
415
595
|
function isMeaningfullyNewer(newer, older) {
|
|
416
596
|
return newer.getTime() - older.getTime() > STALE_OUTPUT_GRACE_MS;
|
|
417
597
|
}
|
|
418
|
-
|
|
419
|
-
|
|
598
|
+
const resultSourceSchema = assemblyStatusResultSchema.pick({
|
|
599
|
+
original_basename: true,
|
|
600
|
+
original_id: true,
|
|
601
|
+
original_name: true,
|
|
602
|
+
original_path: true,
|
|
603
|
+
});
|
|
604
|
+
function groupDirectoryResults(allFiles) {
|
|
605
|
+
const steps = new Map();
|
|
606
|
+
for (const file of allFiles) {
|
|
607
|
+
const files = steps.get(file.stepName) ?? [];
|
|
608
|
+
files.push({ file, relativePath: getResultRelativePath(file) });
|
|
609
|
+
steps.set(file.stepName, files);
|
|
610
|
+
}
|
|
611
|
+
const groups = [];
|
|
612
|
+
for (const [stepName, stepFiles] of steps) {
|
|
613
|
+
if (!stepFiles.some((file) => file.relativePath != null)) {
|
|
614
|
+
groups.push({ directoryName: null, files: stepFiles, ownerKey: null, stepName });
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const sources = new Map();
|
|
618
|
+
for (const file of stepFiles) {
|
|
619
|
+
const parsed = resultSourceSchema.safeParse(file.file.file);
|
|
620
|
+
const source = parsed.success ? parsed.data : undefined;
|
|
621
|
+
const sourceId = JSON.stringify(source?.original_id ?? null);
|
|
622
|
+
const name = source?.original_name ?? source?.original_basename ?? null;
|
|
623
|
+
const group = sources.get(sourceId) ?? {
|
|
624
|
+
// original_path can be a shared directory, so the source filename is also required.
|
|
625
|
+
identity: JSON.stringify([source?.original_path ?? null, name]),
|
|
626
|
+
name,
|
|
627
|
+
files: [],
|
|
628
|
+
};
|
|
629
|
+
group.files.push(file);
|
|
630
|
+
sources.set(sourceId, group);
|
|
631
|
+
}
|
|
632
|
+
const sourceOccurrences = new Map();
|
|
633
|
+
for (const { identity, name, files } of sources.values()) {
|
|
634
|
+
const occurrence = sourceOccurrences.get(identity) ?? 0;
|
|
635
|
+
sourceOccurrences.set(identity, occurrence + 1);
|
|
636
|
+
// Ordinary files can be renamed independently, even when they share an HLS source.
|
|
637
|
+
for (const linked of [true, false]) {
|
|
638
|
+
const groupFiles = files.filter(({ relativePath }) => (relativePath != null) === linked);
|
|
639
|
+
if (groupFiles.length === 0)
|
|
640
|
+
continue;
|
|
641
|
+
groups.push({
|
|
642
|
+
directoryName: sources.size > 1
|
|
643
|
+
? sanitizeResultName(path.parse(name ?? stepName).name, stepName)
|
|
644
|
+
: null,
|
|
645
|
+
files: groupFiles,
|
|
646
|
+
// Upload IDs change between watch runs. Paths and names keep source ownership stable
|
|
647
|
+
// even when results arrive in a different order.
|
|
648
|
+
ownerKey: JSON.stringify([identity, occurrence, linked]),
|
|
649
|
+
stepName,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return groups;
|
|
655
|
+
}
|
|
656
|
+
async function buildDirectoryDownloadTargets({ allFiles, baseDir, groupByStep, owner, outputRoot, overwritePath, reservedPaths, }) {
|
|
657
|
+
await prepareDownloadDirectory(outputRoot, baseDir, reservedPaths);
|
|
420
658
|
const targets = [];
|
|
421
|
-
for (const
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
659
|
+
for (const { directoryName, files, ownerKey, stepName } of groupDirectoryResults(allFiles)) {
|
|
660
|
+
const safeStepName = sanitizeResultName(stepName);
|
|
661
|
+
let targetDir = groupByStep ? path.join(baseDir, safeStepName) : baseDir;
|
|
662
|
+
if (directoryName != null)
|
|
663
|
+
targetDir = path.join(targetDir, directoryName);
|
|
664
|
+
const linked = files.some((file) => file.relativePath != null);
|
|
665
|
+
const groupOwner = JSON.stringify([owner, stepName, ownerKey]);
|
|
666
|
+
const selectedGroupDir = linked ? reservedPaths.pathsByOwner.get(groupOwner) : undefined;
|
|
667
|
+
if (selectedGroupDir != null)
|
|
668
|
+
targetDir = selectedGroupDir;
|
|
669
|
+
const names = files.map(({ file, relativePath }) => relativePath ?? sanitizeResultName(file.name));
|
|
670
|
+
if (linked &&
|
|
671
|
+
new Set(names.map((name) => downloadPathKey(path.join(targetDir, name), reservedPaths)))
|
|
672
|
+
.size !== names.length) {
|
|
673
|
+
// Merged Steps can hide distinct bundles sharing one upload (#619). Reject ambiguous
|
|
674
|
+
// linked paths rather than guessing bundle boundaries and overwriting their contents.
|
|
675
|
+
throw new Error('Assembly results have duplicate relative paths');
|
|
676
|
+
}
|
|
677
|
+
for (const name of names) {
|
|
678
|
+
await assertNoOutputSymlinks(outputRoot, path.join(targetDir, name));
|
|
679
|
+
}
|
|
680
|
+
if (linked) {
|
|
681
|
+
// Source folders share this parent, so changing their suffix cannot fix a blocked parent.
|
|
682
|
+
if (directoryName != null) {
|
|
683
|
+
await prepareDownloadDirectory(outputRoot, path.dirname(targetDir), reservedPaths);
|
|
684
|
+
}
|
|
685
|
+
const initialDir = targetDir;
|
|
686
|
+
// Selection and reservation share the same lock as ordinary result paths. A bundle must
|
|
687
|
+
// move together on collisions, while later watch updates reuse its owned destinations.
|
|
688
|
+
targetDir = await ensureUniqueCounterValue({
|
|
689
|
+
initialValue: initialDir,
|
|
690
|
+
isTaken: async (candidate) => {
|
|
691
|
+
if (candidate !== initialDir &&
|
|
692
|
+
(await isDownloadPathTaken(candidate, reservedPaths, groupOwner)))
|
|
693
|
+
return true;
|
|
694
|
+
const collisions = await Promise.all(names.map((name) => isDownloadPathTaken(path.join(candidate, name), reservedPaths, groupOwner)));
|
|
695
|
+
const collides = collisions.some(Boolean);
|
|
696
|
+
// Moving an already downloaded bundle would leave its previous playlist stale.
|
|
697
|
+
if (collides && selectedGroupDir != null) {
|
|
698
|
+
throw new Error('Assembly result path conflicts with an existing download');
|
|
699
|
+
}
|
|
700
|
+
return collides;
|
|
701
|
+
},
|
|
702
|
+
reserve: (candidate) => {
|
|
703
|
+
if (candidate !== initialDir)
|
|
704
|
+
reserveDownloadPath(candidate, groupOwner, reservedPaths, true);
|
|
705
|
+
for (const name of names) {
|
|
706
|
+
reserveDownloadPath(path.join(candidate, name), groupOwner, reservedPaths);
|
|
707
|
+
}
|
|
708
|
+
reservedPaths.pathsByOwner.set(groupOwner, candidate);
|
|
709
|
+
},
|
|
710
|
+
nextValue: (counter) => {
|
|
711
|
+
if (directoryName != null) {
|
|
712
|
+
return path.join(path.dirname(initialDir), `${directoryName}__${counter}`);
|
|
713
|
+
}
|
|
714
|
+
const suffix = groupByStep ? counter : counter - 1;
|
|
715
|
+
return path.join(baseDir, suffix === 0 ? safeStepName : `${safeStepName}__${suffix}`);
|
|
716
|
+
},
|
|
717
|
+
scope: reservedPaths,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
await prepareDownloadDirectory(outputRoot, targetDir, reservedPaths);
|
|
721
|
+
const nameOccurrences = new Map();
|
|
722
|
+
for (const [index, { file }] of files.entries()) {
|
|
723
|
+
const name = names[index];
|
|
724
|
+
const occurrence = nameOccurrences.get(name) ?? 0;
|
|
725
|
+
nameOccurrences.set(name, occurrence + 1);
|
|
726
|
+
const targetPath = path.join(targetDir, name);
|
|
727
|
+
const selectedPath = linked
|
|
728
|
+
? targetPath
|
|
729
|
+
: await ensureUniquePath(targetPath, reservedPaths, JSON.stringify([groupOwner, name, occurrence]), false, overwritePath != null && path.resolve(targetPath) === path.resolve(overwritePath));
|
|
730
|
+
await assertNoOutputSymlinks(outputRoot, selectedPath);
|
|
731
|
+
targets.push({
|
|
732
|
+
bundleRoot: linked ? targetDir : undefined,
|
|
733
|
+
resultUrl: file.url,
|
|
734
|
+
targetPath: selectedPath,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
428
737
|
}
|
|
429
738
|
return targets;
|
|
430
739
|
}
|
|
@@ -436,20 +745,28 @@ function getSingleResultDownloadTarget(allFiles, targetPath) {
|
|
|
436
745
|
}
|
|
437
746
|
return [{ resultUrl, targetPath }];
|
|
438
747
|
}
|
|
439
|
-
async function resolveResultDownloadTargets({ hasDirectoryInput, inPath, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, reservedPaths, singleAssembly, }) {
|
|
748
|
+
async function resolveResultDownloadTargets({ hasDirectoryInput, inPath, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, owner, reservedPaths, singleAssembly, }) {
|
|
440
749
|
const { allFiles, entries } = normalizedResults;
|
|
441
|
-
const
|
|
750
|
+
const linked = allFiles.some((file) => getResultRelativePath(file) != null);
|
|
751
|
+
// Ordinary CLI outputs retain their input/Step layout. HLS uses linked result paths so
|
|
752
|
+
// playlist references remain reachable; moving those formerly broken outputs is intentional.
|
|
753
|
+
const keepInputStepLayout = outputMode !== 'directory' && inPath != null && allFiles.length > 1 && !linked;
|
|
754
|
+
const shouldGroupByInput = !singleAssembly &&
|
|
755
|
+
inPath != null &&
|
|
756
|
+
(hasDirectoryInput ||
|
|
757
|
+
(inputs.length > 1 && (allFiles.length > 1 || outputMode === 'directory')));
|
|
442
758
|
const resolveDirectoryBaseDir = () => {
|
|
443
|
-
if (
|
|
444
|
-
return
|
|
759
|
+
if (outputPath != null && (keepInputStepLayout || hasDirectoryInput)) {
|
|
760
|
+
return allFiles.length === 1 && outputMode !== 'directory'
|
|
761
|
+
? path.dirname(outputPath)
|
|
762
|
+
: path.join(path.dirname(outputPath), path.parse(outputPath).name);
|
|
445
763
|
}
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
const mappedDir = path.dirname(mappedRelative);
|
|
449
|
-
const mappedStem = path.parse(mappedRelative).name;
|
|
450
|
-
return path.join(outputRoot, mappedDir === '.' ? '' : mappedDir, mappedStem);
|
|
764
|
+
if (!linked && outputMode !== 'directory' && inPath != null && outputPath != null) {
|
|
765
|
+
return path.dirname(outputPath);
|
|
451
766
|
}
|
|
452
|
-
|
|
767
|
+
if (!shouldGroupByInput || inPath == null)
|
|
768
|
+
return outputRoot;
|
|
769
|
+
return path.join(outputRoot, path.parse(path.basename(outputPath ?? inPath)).name);
|
|
453
770
|
};
|
|
454
771
|
if (!outputRootIsDirectory) {
|
|
455
772
|
if (allFiles.length > 1) {
|
|
@@ -465,24 +782,28 @@ async function resolveResultDownloadTargets({ hasDirectoryInput, inPath, inputs,
|
|
|
465
782
|
allFiles,
|
|
466
783
|
baseDir: outputRoot,
|
|
467
784
|
groupByStep: inputs.length === 0,
|
|
785
|
+
owner,
|
|
786
|
+
outputRoot,
|
|
468
787
|
reservedPaths,
|
|
469
788
|
});
|
|
470
789
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
if (allFiles.length === 1) {
|
|
480
|
-
return getSingleResultDownloadTarget(allFiles, outputPath);
|
|
790
|
+
let baseDir = resolveDirectoryBaseDir();
|
|
791
|
+
if (path.resolve(baseDir) !== path.resolve(outputRoot)) {
|
|
792
|
+
// Input grouping directories share the result namespace, including files already reserved
|
|
793
|
+
// by another Assembly. Reuse existing directories and suffix a conflicting filename.
|
|
794
|
+
await assertNoOutputSymlinks(outputRoot, baseDir);
|
|
795
|
+
await prepareDownloadDirectory(outputRoot, path.dirname(baseDir), reservedPaths);
|
|
796
|
+
baseDir = await ensureUniquePath(baseDir, reservedPaths, JSON.stringify([owner, 'input-directory']), true);
|
|
481
797
|
}
|
|
482
798
|
return await buildDirectoryDownloadTargets({
|
|
483
799
|
allFiles,
|
|
484
|
-
baseDir
|
|
485
|
-
groupByStep:
|
|
800
|
+
baseDir,
|
|
801
|
+
groupByStep: keepInputStepLayout || inPath == null || entries.length > 1,
|
|
802
|
+
owner,
|
|
803
|
+
outputRoot,
|
|
804
|
+
// The mapped single-file destination is also the historical freshness marker. Updating it
|
|
805
|
+
// lets an unchanged rerun skip processing; differently named results retain collision suffixes.
|
|
806
|
+
overwritePath: inPath != null && allFiles.length === 1 && !linked ? (outputPath ?? undefined) : undefined,
|
|
486
807
|
reservedPaths,
|
|
487
808
|
});
|
|
488
809
|
}
|
|
@@ -521,11 +842,12 @@ async function shouldSkipStaleOutput({ inputPaths, outputPath, outputPlanMtime,
|
|
|
521
842
|
return inputStat != null && isMeaningfullyNewer(outputStat.mtime, inputStat.mtime);
|
|
522
843
|
});
|
|
523
844
|
}
|
|
524
|
-
async function materializeAssemblyResults({ abortSignal, hasDirectoryInput, inPath, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, outputctl, reservedPaths, singleAssembly, }) {
|
|
845
|
+
async function materializeAssemblyResults({ abortSignal, hasDirectoryInput, inPath, isCurrent, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, outputctl, owner, reservedPaths, singleAssembly, }) {
|
|
525
846
|
if (outputRoot == null) {
|
|
526
847
|
return;
|
|
527
848
|
}
|
|
528
|
-
|
|
849
|
+
// Directory creation and filename reservation must share a lock across concurrent jobs.
|
|
850
|
+
const targets = await reservedPaths.allocations.add(async () => await resolveResultDownloadTargets({
|
|
529
851
|
hasDirectoryInput,
|
|
530
852
|
inPath,
|
|
531
853
|
inputs,
|
|
@@ -534,14 +856,32 @@ async function materializeAssemblyResults({ abortSignal, hasDirectoryInput, inPa
|
|
|
534
856
|
outputPath,
|
|
535
857
|
outputRoot,
|
|
536
858
|
outputRootIsDirectory,
|
|
859
|
+
owner,
|
|
537
860
|
reservedPaths,
|
|
538
861
|
singleAssembly,
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
862
|
+
}));
|
|
863
|
+
const bundles = new Map();
|
|
864
|
+
const downloads = [];
|
|
865
|
+
for (const { bundleRoot, resultUrl, targetPath } of targets) {
|
|
866
|
+
if (bundleRoot != null && targetPath != null) {
|
|
867
|
+
const files = bundles.get(bundleRoot) ?? [];
|
|
868
|
+
files.push({ resultUrl, targetPath });
|
|
869
|
+
bundles.set(bundleRoot, files);
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
downloads.push(async () => {
|
|
873
|
+
outputctl.debug('DOWNLOADING');
|
|
874
|
+
if (targetPath == null)
|
|
875
|
+
await downloadResultToStdout(resultUrl, abortSignal);
|
|
876
|
+
else
|
|
877
|
+
await downloadResultToFile(resultUrl, targetPath, abortSignal, isCurrent);
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
for (const files of bundles.values()) {
|
|
881
|
+
downloads.push(async () => await downloadResultBundle(files, abortSignal, isCurrent, reservedPaths.bundleCommits, () => outputctl.debug('DOWNLOADING')));
|
|
882
|
+
}
|
|
883
|
+
for (const download of downloads) {
|
|
884
|
+
const [dlErr] = await tryCatch(download());
|
|
545
885
|
if (dlErr) {
|
|
546
886
|
if (dlErr.name === 'AbortError') {
|
|
547
887
|
continue;
|
|
@@ -784,7 +1124,8 @@ function dismissStaleJobs(jobEmitter) {
|
|
|
784
1124
|
jobEmitter.on('end', () => Promise.all(pendingChecks).then(() => emitter.emit('end')));
|
|
785
1125
|
jobEmitter.on('error', (err) => emitter.emit('error', err));
|
|
786
1126
|
jobEmitter.on('job', (job) => {
|
|
787
|
-
|
|
1127
|
+
// An older download can finish after a watch change and have a misleading newer mtime.
|
|
1128
|
+
if (job.watchEvent || job.inputPath == null || job.out == null) {
|
|
788
1129
|
emitter.emit('job', job);
|
|
789
1130
|
return;
|
|
790
1131
|
}
|
|
@@ -867,8 +1208,11 @@ function makeJobEmitter(inputs, { allowOutputCollisions, recursive, outputPlanPr
|
|
|
867
1208
|
return staleFilter(conflictFilter(emitter));
|
|
868
1209
|
}
|
|
869
1210
|
const DEFAULT_CONCURRENCY = 5;
|
|
870
|
-
|
|
871
|
-
|
|
1211
|
+
const DEFAULT_URL_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
|
|
1212
|
+
function isUrlInput(input) {
|
|
1213
|
+
return /^https?:\/\//i.test(input);
|
|
1214
|
+
}
|
|
1215
|
+
async function prepareCreateOptions(outputctl, { steps, stepsData, outputMode, output, inputs, singleAssembly, transientInputPaths, }) {
|
|
872
1216
|
// Quick fix for https://github.com/transloadit/transloadify/issues/13
|
|
873
1217
|
// Only default to stdout when output is undefined (not provided), not when explicitly null
|
|
874
1218
|
let resolvedOutput = output;
|
|
@@ -901,7 +1245,9 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
901
1245
|
if (!outstat.isDirectory() && inputs.length !== 0 && !singleAssembly) {
|
|
902
1246
|
const firstInput = inputs[0];
|
|
903
1247
|
if (firstInput) {
|
|
904
|
-
const firstInputStat =
|
|
1248
|
+
const firstInputStat = isUrlInput(firstInput)
|
|
1249
|
+
? { isDirectory: () => false }
|
|
1250
|
+
: await myStat(process.stdin, firstInput);
|
|
905
1251
|
if (inputs.length > 1 || firstInputStat.isDirectory()) {
|
|
906
1252
|
const msg = 'Output must be a directory when specifying multiple inputs';
|
|
907
1253
|
outputctl.error(msg);
|
|
@@ -911,11 +1257,70 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
911
1257
|
}
|
|
912
1258
|
}
|
|
913
1259
|
const inputStats = await Promise.all(inputs.map(async (input) => {
|
|
914
|
-
if (input === '-')
|
|
1260
|
+
if (input === '-' || isUrlInput(input))
|
|
915
1261
|
return null;
|
|
916
1262
|
return await myStat(process.stdin, input);
|
|
917
1263
|
}));
|
|
918
1264
|
const hasDirectoryInput = inputStats.some((stat) => stat?.isDirectory() === true);
|
|
1265
|
+
return {
|
|
1266
|
+
effectiveStepsData,
|
|
1267
|
+
hasDirectoryInput,
|
|
1268
|
+
outstat,
|
|
1269
|
+
resolvedOutput,
|
|
1270
|
+
transientInputPaths: transientInputPaths == null
|
|
1271
|
+
? undefined
|
|
1272
|
+
: new Set(transientInputPaths.map((inputPath) => path.resolve(inputPath))),
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
// --- Main assembly create function ---
|
|
1276
|
+
/** Create Assemblies after materializing URL inputs as temporary local uploads. */
|
|
1277
|
+
export async function create(outputctl, client, options) {
|
|
1278
|
+
const preparedOptions = await prepareCreateOptions(outputctl, options);
|
|
1279
|
+
const urlDownloadTimeoutMs = toPositiveInteger(options.urlDownloadTimeoutMs, '--url-download-timeout') ??
|
|
1280
|
+
DEFAULT_URL_DOWNLOAD_TIMEOUT_MS;
|
|
1281
|
+
if (!options.inputs.some(isUrlInput))
|
|
1282
|
+
return await createWithLocalInputs(outputctl, client, options, preparedOptions);
|
|
1283
|
+
if (options.watch)
|
|
1284
|
+
throw new Error('--watch requires local inputs');
|
|
1285
|
+
const preparedInputs = [];
|
|
1286
|
+
const localInputs = [];
|
|
1287
|
+
const transientInputPaths = preparedOptions.transientInputPaths ?? new Set();
|
|
1288
|
+
try {
|
|
1289
|
+
for (const [index, input] of options.inputs.entries()) {
|
|
1290
|
+
if (!isUrlInput(input)) {
|
|
1291
|
+
localInputs.push(input);
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
const field = String(index);
|
|
1295
|
+
// Separate staging roots protect case-only filenames and repeated URLs from aliasing.
|
|
1296
|
+
const prepared = await prepareInputFiles({
|
|
1297
|
+
inputFiles: [{ kind: 'url', field, url: input }],
|
|
1298
|
+
allowPrivateUrls: false,
|
|
1299
|
+
// Uploading preserves :original and Template semantics, just as Intents do.
|
|
1300
|
+
urlStrategy: 'download',
|
|
1301
|
+
urlDownloadTimeoutMs,
|
|
1302
|
+
});
|
|
1303
|
+
preparedInputs.push(prepared);
|
|
1304
|
+
const localPath = prepared.files[field];
|
|
1305
|
+
if (localPath == null)
|
|
1306
|
+
throw new Error('URL input could not be prepared');
|
|
1307
|
+
localInputs.push(localPath);
|
|
1308
|
+
transientInputPaths.add(path.resolve(localPath));
|
|
1309
|
+
}
|
|
1310
|
+
return await createWithLocalInputs(outputctl, client, {
|
|
1311
|
+
...options,
|
|
1312
|
+
inputs: localInputs,
|
|
1313
|
+
}, { ...preparedOptions, transientInputPaths });
|
|
1314
|
+
}
|
|
1315
|
+
finally {
|
|
1316
|
+
await Promise.all(preparedInputs.flatMap((prepared) => prepared.cleanup.map((cleanup) => cleanup())));
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
async function createWithLocalInputs(outputctl, client, { template, fields, outputMode, watch: watchOption, recursive, inputs, del, reprocessStale, singleAssembly, concurrency = DEFAULT_CONCURRENCY, printUrls = false, }, preparedOptions) {
|
|
1320
|
+
const { effectiveStepsData, hasDirectoryInput, outstat, resolvedOutput } = preparedOptions;
|
|
1321
|
+
const nameSensitivity = resolvedOutput != null && outstat?.isDirectory()
|
|
1322
|
+
? await outputNameSensitivity(resolvedOutput)
|
|
1323
|
+
: { caseSensitive: true, normalizationSensitive: true };
|
|
919
1324
|
return new Promise((resolve, reject) => {
|
|
920
1325
|
const params = (effectiveStepsData
|
|
921
1326
|
? { steps: effectiveStepsData }
|
|
@@ -926,7 +1331,7 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
926
1331
|
const outputPlanProvider = resolvedOutput == null
|
|
927
1332
|
? nullProvider()
|
|
928
1333
|
: outstat?.isDirectory()
|
|
929
|
-
? dirProvider(resolvedOutput)
|
|
1334
|
+
? dirProvider(resolvedOutput, preparedOptions.transientInputPaths)
|
|
930
1335
|
: fileProvider(resolvedOutput);
|
|
931
1336
|
const emitter = makeJobEmitter(inputs, {
|
|
932
1337
|
allowOutputCollisions: singleAssembly,
|
|
@@ -939,8 +1344,16 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
939
1344
|
// Use p-queue for concurrency management
|
|
940
1345
|
const queue = new PQueue({ concurrency });
|
|
941
1346
|
const results = [];
|
|
1347
|
+
const assemblies = [];
|
|
942
1348
|
const resultUrls = [];
|
|
943
|
-
const reservedResultPaths =
|
|
1349
|
+
const reservedResultPaths = {
|
|
1350
|
+
allocations: new PQueue({ concurrency: 1 }),
|
|
1351
|
+
bundleCommits: new PQueue({ concurrency: 1 }),
|
|
1352
|
+
...nameSensitivity,
|
|
1353
|
+
directories: new Set(),
|
|
1354
|
+
ownersByPath: new Map(),
|
|
1355
|
+
pathsByOwner: new Map(),
|
|
1356
|
+
};
|
|
944
1357
|
const latestWatchJobTokenByOutputPath = new Map();
|
|
945
1358
|
let hasFailures = false;
|
|
946
1359
|
let nextWatchJobToken = 0;
|
|
@@ -975,28 +1388,88 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
975
1388
|
return createOptions;
|
|
976
1389
|
}
|
|
977
1390
|
async function awaitCompletedAssembly(createOptions) {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1391
|
+
let assemblyReference;
|
|
1392
|
+
function reportCreatedAssembly(rawAssembly) {
|
|
1393
|
+
const assembly = assemblySummarySchema.parse(rawAssembly);
|
|
1394
|
+
if (assemblyReference || !assembly.assembly_id)
|
|
1395
|
+
return;
|
|
1396
|
+
assemblyReference = formatAssemblyReference({
|
|
1397
|
+
assemblyId: assembly.assembly_id,
|
|
1398
|
+
assemblyUrl: assembly.assembly_ssl_url ?? assembly.assembly_url ?? null,
|
|
1399
|
+
});
|
|
1400
|
+
outputctl.notice(assemblyReference);
|
|
1401
|
+
}
|
|
1402
|
+
const creation = client.createAssembly({
|
|
1403
|
+
...createOptions,
|
|
1404
|
+
onAssemblyCreated: reportCreatedAssembly,
|
|
1405
|
+
});
|
|
1406
|
+
const [creationError, result] = await tryCatch(creation);
|
|
1407
|
+
if (creationError) {
|
|
1408
|
+
if (!assemblyReference && creationError instanceof RequestError && creation.assemblyId) {
|
|
1409
|
+
const requestUrl = creationError.options.url;
|
|
1410
|
+
assemblyReference = formatAssemblyReference({
|
|
1411
|
+
assemblyId: creation.assemblyId,
|
|
1412
|
+
assemblyUrl: requestUrl instanceof URL ? `${requestUrl.origin}${requestUrl.pathname}` : null,
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
if (!assemblyReference)
|
|
1416
|
+
throw creationError;
|
|
1417
|
+
throw new Error(`${formatAPIError(creationError)} (${assemblyReference})`, {
|
|
1418
|
+
cause: creationError,
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
reportCreatedAssembly(result);
|
|
1422
|
+
const createdSummary = assemblySummarySchema.parse(result);
|
|
1423
|
+
const assemblyId = createdSummary.assembly_id;
|
|
1424
|
+
if (!assemblyId || !assemblyReference)
|
|
981
1425
|
throw new Error('No assembly_id in result');
|
|
982
|
-
const assemblyUrl = result.assembly_ssl_url ?? result.assembly_url ?? null;
|
|
983
|
-
const assemblyReference = formatAssemblyReference({ assemblyId, assemblyUrl });
|
|
984
1426
|
let assembly;
|
|
985
1427
|
try {
|
|
986
1428
|
assembly = await client.awaitAssemblyCompletion(assemblyId, {
|
|
987
1429
|
signal: abortController.signal,
|
|
988
1430
|
onPoll: () => true,
|
|
989
1431
|
onAssemblyProgress: (status) => {
|
|
990
|
-
|
|
1432
|
+
const summary = assemblySummarySchema.parse(status);
|
|
1433
|
+
outputctl.debug(`Assembly status: ${summary.ok ?? 'unknown'}`);
|
|
991
1434
|
},
|
|
992
1435
|
});
|
|
993
1436
|
}
|
|
994
1437
|
catch (err) {
|
|
995
1438
|
const error = ensureError(err);
|
|
996
|
-
|
|
1439
|
+
const message = error instanceof RequestError ? formatAPIError(error) : error.message;
|
|
1440
|
+
throw new Error(`${message} (${assemblyReference})`, { cause: error });
|
|
997
1441
|
}
|
|
1442
|
+
const summary = assemblySummarySchema.parse(assembly);
|
|
1443
|
+
summary.assembly_id = assemblyId;
|
|
1444
|
+
summary.assembly_url ??= createdSummary.assembly_url;
|
|
1445
|
+
summary.assembly_ssl_url ??= createdSummary.assembly_ssl_url;
|
|
1446
|
+
const ignoredErrors = summary.ignored_errors;
|
|
1447
|
+
const ignoredErrorCount = Math.max(summary.ignored_error_count, ignoredErrors.length);
|
|
1448
|
+
summary.ignored_error_count = ignoredErrorCount;
|
|
1449
|
+
// Preserve stdout file bytes. URL mode combines all summaries with its existing JSON object.
|
|
1450
|
+
if (resolvedOutput !== '-') {
|
|
1451
|
+
if (printUrls)
|
|
1452
|
+
assemblies.push(summary);
|
|
1453
|
+
else
|
|
1454
|
+
outputctl.print(undefined, summary);
|
|
1455
|
+
}
|
|
1456
|
+
if (ignoredErrorCount > 0) {
|
|
1457
|
+
const details = ignoredErrors
|
|
1458
|
+
.map((ignored) => {
|
|
1459
|
+
const code = typeof ignored.error === 'string' ? ignored.error : ignored.error?.code;
|
|
1460
|
+
const message = ignored.message ||
|
|
1461
|
+
(typeof ignored.error === 'object' ? ignored.error.message : undefined);
|
|
1462
|
+
return [ignored.step, code, message].filter(Boolean).join(': ');
|
|
1463
|
+
})
|
|
1464
|
+
.filter(Boolean)
|
|
1465
|
+
.slice(0, 5);
|
|
1466
|
+
outputctl.warn(`${ignoredErrorCount} ignored error${ignoredErrorCount === 1 ? '' : 's'} (${assemblyReference})${details.length > 0 ? `: ${details.join('; ')}` : ''}`);
|
|
1467
|
+
}
|
|
1468
|
+
// Malformed error fields still signal failure, but only sanitized values enter the message.
|
|
998
1469
|
if (assembly.error || (assembly.ok && assembly.ok !== 'ASSEMBLY_COMPLETED')) {
|
|
999
|
-
const
|
|
1470
|
+
const details = [summary.error ?? summary.ok, summary.message].filter(Boolean).join(': ') ||
|
|
1471
|
+
'Unknown Assembly error';
|
|
1472
|
+
const msg = `Assembly failed: ${details} (${assemblyReference})`;
|
|
1000
1473
|
throw new Error(msg);
|
|
1001
1474
|
}
|
|
1002
1475
|
return { assembly, assemblyId };
|
|
@@ -1028,16 +1501,26 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
1028
1501
|
abortSignal: abortController.signal,
|
|
1029
1502
|
hasDirectoryInput: singleAssemblyMode ? false : hasDirectoryInput,
|
|
1030
1503
|
inPath,
|
|
1031
|
-
|
|
1504
|
+
isCurrent: () => !isSupersededWatchJob(outputPlan?.path ?? null, outputToken),
|
|
1505
|
+
inputs: singleAssemblyMode ? inputPaths : inputs,
|
|
1032
1506
|
normalizedResults,
|
|
1033
1507
|
outputMode,
|
|
1034
1508
|
outputPath: outputPlan?.path ?? null,
|
|
1035
1509
|
outputRoot: resolvedOutput ?? null,
|
|
1036
1510
|
outputRootIsDirectory,
|
|
1037
1511
|
outputctl,
|
|
1512
|
+
// Only watch updates reuse destinations; independent Assemblies retain every result.
|
|
1513
|
+
owner: JSON.stringify([
|
|
1514
|
+
inPath,
|
|
1515
|
+
singleAssemblyMode ? inputPaths : [],
|
|
1516
|
+
watchOption ? null : assemblyId,
|
|
1517
|
+
]),
|
|
1038
1518
|
reservedPaths: reservedResultPaths,
|
|
1039
1519
|
singleAssembly: singleAssemblyMode,
|
|
1040
1520
|
});
|
|
1521
|
+
// Only the current watch job may delete its input; a failed newer job must retain it.
|
|
1522
|
+
if (isSupersededWatchJob(outputPlan?.path ?? null, outputToken))
|
|
1523
|
+
return assembly;
|
|
1041
1524
|
outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`);
|
|
1042
1525
|
if (del) {
|
|
1043
1526
|
for (const inputPath of inputPaths) {
|
|
@@ -1072,7 +1555,7 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
1072
1555
|
function handleEmitterError(err) {
|
|
1073
1556
|
abortController.abort();
|
|
1074
1557
|
queue.clear();
|
|
1075
|
-
outputctl.error(err);
|
|
1558
|
+
outputctl.error(formatAPIError(err));
|
|
1076
1559
|
reject(err);
|
|
1077
1560
|
}
|
|
1078
1561
|
function runSingleAssemblyEmitter() {
|
|
@@ -1097,7 +1580,7 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
1097
1580
|
singleInputReference: 'input',
|
|
1098
1581
|
})) {
|
|
1099
1582
|
outputctl.debug(`SKIPPED STALE SINGLE ASSEMBLY ${resolvedOutput ?? 'null'}`);
|
|
1100
|
-
resolve({ resultUrls, results: [], hasFailures: false });
|
|
1583
|
+
resolve({ assemblies, resultUrls, results: [], hasFailures: false });
|
|
1101
1584
|
return;
|
|
1102
1585
|
}
|
|
1103
1586
|
// Preserve original basenames/extensions for filesystem uploads so the backend
|
|
@@ -1140,9 +1623,9 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
1140
1623
|
}
|
|
1141
1624
|
catch (err) {
|
|
1142
1625
|
hasFailures = true;
|
|
1143
|
-
outputctl.error(err);
|
|
1626
|
+
outputctl.error(formatAPIError(err));
|
|
1144
1627
|
}
|
|
1145
|
-
resolve({ resultUrls, results, hasFailures });
|
|
1628
|
+
resolve({ assemblies, resultUrls, results, hasFailures });
|
|
1146
1629
|
});
|
|
1147
1630
|
}
|
|
1148
1631
|
function runPerFileEmitter() {
|
|
@@ -1160,12 +1643,12 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
|
|
|
1160
1643
|
})
|
|
1161
1644
|
.catch((err) => {
|
|
1162
1645
|
hasFailures = true;
|
|
1163
|
-
outputctl.error(err);
|
|
1646
|
+
outputctl.error(formatAPIError(err));
|
|
1164
1647
|
});
|
|
1165
1648
|
});
|
|
1166
1649
|
emitter.on('end', async () => {
|
|
1167
1650
|
await queue.onIdle();
|
|
1168
|
-
resolve({ resultUrls, results, hasFailures });
|
|
1651
|
+
resolve({ assemblies, resultUrls, results, hasFailures });
|
|
1169
1652
|
});
|
|
1170
1653
|
}
|
|
1171
1654
|
emitter.on('error', handleEmitterError);
|
|
@@ -1276,6 +1759,9 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
|
|
|
1276
1759
|
reprocessStale = reprocessStaleOption();
|
|
1277
1760
|
singleAssembly = singleAssemblyOption();
|
|
1278
1761
|
concurrency = concurrencyOption();
|
|
1762
|
+
urlDownloadTimeoutMs = Option.String('--url-download-timeout', {
|
|
1763
|
+
description: 'Maximum time per URL input download in milliseconds (default: 300000)',
|
|
1764
|
+
});
|
|
1279
1765
|
printUrls = printUrlsOption();
|
|
1280
1766
|
async run() {
|
|
1281
1767
|
if (!this.steps && !this.template) {
|
|
@@ -1305,7 +1791,7 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
|
|
|
1305
1791
|
this.output.error(sharedValidationError);
|
|
1306
1792
|
return 1;
|
|
1307
1793
|
}
|
|
1308
|
-
const { hasFailures, resultUrls } = await create(this.output, this.client, {
|
|
1794
|
+
const { assemblies, hasFailures, resultUrls } = await create(this.output, this.client, {
|
|
1309
1795
|
steps: this.steps,
|
|
1310
1796
|
template: this.template,
|
|
1311
1797
|
fields: fieldsMap ?? {},
|
|
@@ -1317,9 +1803,11 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
|
|
|
1317
1803
|
reprocessStale: this.reprocessStale,
|
|
1318
1804
|
singleAssembly: this.singleAssembly,
|
|
1319
1805
|
concurrency: this.concurrency == null ? undefined : Number(this.concurrency),
|
|
1806
|
+
printUrls: this.printUrls,
|
|
1807
|
+
urlDownloadTimeoutMs: this.urlDownloadTimeoutMs == null ? undefined : Number(this.urlDownloadTimeoutMs),
|
|
1320
1808
|
});
|
|
1321
1809
|
if (this.printUrls) {
|
|
1322
|
-
printResultUrls(this.output, resultUrls);
|
|
1810
|
+
printResultUrls(this.output, resultUrls, assemblies);
|
|
1323
1811
|
}
|
|
1324
1812
|
return hasFailures ? 1 : undefined;
|
|
1325
1813
|
}
|
|
@@ -1371,6 +1859,9 @@ export class RunCommand extends AuthenticatedCommand {
|
|
|
1371
1859
|
reprocessStale = reprocessStaleOption();
|
|
1372
1860
|
singleAssembly = singleAssemblyOption();
|
|
1373
1861
|
concurrency = concurrencyOption();
|
|
1862
|
+
urlDownloadTimeoutMs = Option.String('--url-download-timeout', {
|
|
1863
|
+
description: 'Maximum time per URL input download in milliseconds (default: 300000)',
|
|
1864
|
+
});
|
|
1374
1865
|
printUrls = printUrlsOption();
|
|
1375
1866
|
async run() {
|
|
1376
1867
|
const inputList = this.inputs ?? [];
|
|
@@ -1405,7 +1896,7 @@ export class RunCommand extends AuthenticatedCommand {
|
|
|
1405
1896
|
this.output.error(formatAPIError(error));
|
|
1406
1897
|
return 1;
|
|
1407
1898
|
}
|
|
1408
|
-
const { hasFailures, resultUrls } = await create(this.output, this.client, {
|
|
1899
|
+
const { assemblies, hasFailures, resultUrls } = await create(this.output, this.client, {
|
|
1409
1900
|
stepsData: parseStepsInputJson(JSON.stringify(compiled.instructions.steps), this.output),
|
|
1410
1901
|
fields: fieldsMap ?? {},
|
|
1411
1902
|
watch: this.watch,
|
|
@@ -1416,9 +1907,11 @@ export class RunCommand extends AuthenticatedCommand {
|
|
|
1416
1907
|
reprocessStale: this.reprocessStale,
|
|
1417
1908
|
singleAssembly: this.singleAssembly,
|
|
1418
1909
|
concurrency: this.concurrency == null ? undefined : Number(this.concurrency),
|
|
1910
|
+
printUrls: this.printUrls,
|
|
1911
|
+
urlDownloadTimeoutMs: this.urlDownloadTimeoutMs == null ? undefined : Number(this.urlDownloadTimeoutMs),
|
|
1419
1912
|
});
|
|
1420
1913
|
if (this.printUrls) {
|
|
1421
|
-
printResultUrls(this.output, resultUrls);
|
|
1914
|
+
printResultUrls(this.output, resultUrls, assemblies);
|
|
1422
1915
|
}
|
|
1423
1916
|
return hasFailures ? 1 : undefined;
|
|
1424
1917
|
}
|