Fix XWorkmate artifact export fallback
This commit is contained in:
parent
961e8391db
commit
ccbad4934e
19
README.md
19
README.md
@ -143,9 +143,21 @@ derived from `sessionKey/runId`. If `sinceUnixMs` is provided, export also
|
|||||||
adopts files created or changed in the workspace root during the current run by
|
adopts files created or changed in the workspace root during the current run by
|
||||||
copying them into that task scope before returning the manifest. This covers
|
copying them into that task scope before returning the manifest. This covers
|
||||||
agents that save output as `./file.md` while still keeping XWorkmate sync scoped
|
agents that save output as `./file.md` while still keeping XWorkmate sync scoped
|
||||||
to `tasks/<session>/<run>`. Without `sinceUnixMs`, export/list only reads the
|
to `tasks/<session>/<run>`.
|
||||||
current task scope. The plugin never scans `tasks/` as a fallback and does not
|
|
||||||
borrow artifacts from earlier task scopes.
|
When `sinceUnixMs` is provided and the prepared task scope plus current-run
|
||||||
|
workspace-root adoption are both empty, export falls back to explicit delivery
|
||||||
|
files in the same OpenClaw thread workspace for the supplied `sessionKey`. This
|
||||||
|
handles long-running agents that finish in OpenClaw's owner/thread workspace
|
||||||
|
instead of the prepared `tasks/<session>/<run>` directory. The fallback only
|
||||||
|
copies known deliverables such as `DELIVERY.md`, `ffprobe.json`,
|
||||||
|
`video.config.json`, `index.html`, and files under delivery directories like
|
||||||
|
`renders/`; it does not scan other threads or borrow artifacts from earlier
|
||||||
|
task scopes.
|
||||||
|
|
||||||
|
Without `sinceUnixMs`, export/list only reads the current task scope. The plugin
|
||||||
|
never scans `tasks/` as a fallback and does not borrow artifacts from earlier
|
||||||
|
task scopes.
|
||||||
|
|
||||||
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
||||||
the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts
|
the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts
|
||||||
@ -205,6 +217,7 @@ only remote file access path.
|
|||||||
- Only files inside the resolved OpenClaw workspace are exported.
|
- Only files inside the resolved OpenClaw workspace are exported.
|
||||||
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are excluded from task artifact exports.
|
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are excluded from task artifact exports.
|
||||||
- Workspace-root files are adopted only when `sinceUnixMs` is provided; adopted files are copied into the current `tasks/<safe-session-key>/<safe-run-id>` scope before listing or reading.
|
- Workspace-root files are adopted only when `sinceUnixMs` is provided; adopted files are copied into the current `tasks/<safe-session-key>/<safe-run-id>` scope before listing or reading.
|
||||||
|
- If `sinceUnixMs` is provided and task-scope plus workspace-root adoption are empty, export may adopt explicit deliverables from the same `sessionKey` owner/thread workspace. This fallback is limited to known delivery files and delivery directories, and never scans other thread workspaces.
|
||||||
- Symlinks are skipped to avoid workspace escape.
|
- Symlinks are skipped to avoid workspace escape.
|
||||||
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
|
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
|
||||||
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
|
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
|
||||||
|
|||||||
194
dist/src/exportArtifacts.js
vendored
194
dist/src/exportArtifacts.js
vendored
@ -18,6 +18,32 @@ const SKIPPED_DIRS = new Set([
|
|||||||
"dist",
|
"dist",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
]);
|
]);
|
||||||
|
const THREAD_DELIVERY_FILE_NAMES = new Set([
|
||||||
|
"DELIVERY.md",
|
||||||
|
"delivery.md",
|
||||||
|
"ffprobe.json",
|
||||||
|
"video.config.json",
|
||||||
|
"index.html",
|
||||||
|
]);
|
||||||
|
const THREAD_DELIVERY_DIRS = new Set([
|
||||||
|
"renders",
|
||||||
|
"render",
|
||||||
|
"exports",
|
||||||
|
"output",
|
||||||
|
"outputs",
|
||||||
|
]);
|
||||||
|
const THREAD_DELIVERY_EXTENSIONS = new Set([
|
||||||
|
".mp4",
|
||||||
|
".mov",
|
||||||
|
".webm",
|
||||||
|
".pdf",
|
||||||
|
".docx",
|
||||||
|
".pptx",
|
||||||
|
".xlsx",
|
||||||
|
".md",
|
||||||
|
".html",
|
||||||
|
".json",
|
||||||
|
]);
|
||||||
export async function prepareXWorkmateArtifacts(input) {
|
export async function prepareXWorkmateArtifacts(input) {
|
||||||
const params = input.params ?? {};
|
const params = input.params ?? {};
|
||||||
const pluginConfig = input.pluginConfig ?? {};
|
const pluginConfig = input.pluginConfig ?? {};
|
||||||
@ -86,6 +112,7 @@ export async function exportXWorkmateArtifacts(input) {
|
|||||||
relativeRoot: scopeRoot,
|
relativeRoot: scopeRoot,
|
||||||
sinceUnixMs,
|
sinceUnixMs,
|
||||||
skipTaskScopeRoot: false,
|
skipTaskScopeRoot: false,
|
||||||
|
warnSkippedSymlinks: true,
|
||||||
warnings,
|
warnings,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
@ -99,7 +126,17 @@ export async function exportXWorkmateArtifacts(input) {
|
|||||||
warnings,
|
warnings,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const candidates = [...scopedCandidates, ...adoptedCandidates];
|
const threadDeliveryCandidates = sinceUnixMs > 0 && scopedCandidates.length === 0 && adoptedCandidates.length === 0
|
||||||
|
? await adoptThreadWorkspaceDeliveryCandidatesIntoScope({
|
||||||
|
workspaceRoot,
|
||||||
|
scopeRoot,
|
||||||
|
artifactScope,
|
||||||
|
sessionKey,
|
||||||
|
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const candidates = [...scopedCandidates, ...adoptedCandidates, ...threadDeliveryCandidates];
|
||||||
if (!scopePrepared && candidates.length === 0) {
|
if (!scopePrepared && candidates.length === 0) {
|
||||||
warnings.push("artifact scope is not prepared for this task run");
|
warnings.push("artifact scope is not prepared for this task run");
|
||||||
}
|
}
|
||||||
@ -302,6 +339,7 @@ async function adoptWorkspaceRootCandidatesIntoScope(input) {
|
|||||||
relativeRoot: input.workspaceRoot,
|
relativeRoot: input.workspaceRoot,
|
||||||
sinceUnixMs: input.sinceUnixMs,
|
sinceUnixMs: input.sinceUnixMs,
|
||||||
skipTaskScopeRoot: true,
|
skipTaskScopeRoot: true,
|
||||||
|
warnSkippedSymlinks: false,
|
||||||
warnings: input.warnings,
|
warnings: input.warnings,
|
||||||
});
|
});
|
||||||
const adopted = [];
|
const adopted = [];
|
||||||
@ -334,6 +372,152 @@ async function adoptWorkspaceRootCandidatesIntoScope(input) {
|
|||||||
}
|
}
|
||||||
return adopted;
|
return adopted;
|
||||||
}
|
}
|
||||||
|
async function adoptThreadWorkspaceDeliveryCandidatesIntoScope(input) {
|
||||||
|
const threadRoots = await resolveCurrentThreadWorkspaceRoots(input.workspaceRoot, input.sessionKey);
|
||||||
|
const adopted = [];
|
||||||
|
for (const threadRoot of threadRoots) {
|
||||||
|
const candidates = await collectThreadDeliveryCandidates({
|
||||||
|
scanRoot: threadRoot,
|
||||||
|
relativeRoot: threadRoot,
|
||||||
|
warnings: input.warnings,
|
||||||
|
});
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (input.existingRelativePaths.has(candidate.relativePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep));
|
||||||
|
if (!isWithinRoot(input.scopeRoot, targetPath)) {
|
||||||
|
input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (await fileExists(targetPath)) {
|
||||||
|
input.existingRelativePaths.add(candidate.relativePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fs.copyFile(candidate.absolutePath, targetPath);
|
||||||
|
const stat = await fs.stat(targetPath);
|
||||||
|
const realPath = await fs.realpath(targetPath);
|
||||||
|
adopted.push({
|
||||||
|
absolutePath: realPath,
|
||||||
|
relativePath: candidate.relativePath,
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
mtimeMs: candidate.mtimeMs,
|
||||||
|
artifactScope: input.artifactScope,
|
||||||
|
scopeKind: "task",
|
||||||
|
});
|
||||||
|
input.existingRelativePaths.add(candidate.relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return adopted;
|
||||||
|
}
|
||||||
|
async function resolveCurrentThreadWorkspaceRoots(workspaceRoot, sessionKey) {
|
||||||
|
const roots = new Set();
|
||||||
|
const realWorkspaceRoot = await fs.realpath(workspaceRoot);
|
||||||
|
if (path.basename(realWorkspaceRoot) === sessionKey) {
|
||||||
|
roots.add(realWorkspaceRoot);
|
||||||
|
}
|
||||||
|
const ownerRoots = [
|
||||||
|
path.join(realWorkspaceRoot, "owners", "local", "user"),
|
||||||
|
path.join(realWorkspaceRoot, "owners", "remote", "user"),
|
||||||
|
];
|
||||||
|
for (const ownerRoot of ownerRoots) {
|
||||||
|
if (!(await directoryExists(ownerRoot))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ownerEntries;
|
||||||
|
try {
|
||||||
|
ownerEntries = await fs.readdir(ownerRoot, { withFileTypes: true });
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const ownerEntry of ownerEntries) {
|
||||||
|
if (!ownerEntry.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = path.join(ownerRoot, ownerEntry.name, "threads", sessionKey);
|
||||||
|
if (!(await directoryExists(candidate))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const realCandidate = await fs.realpath(candidate);
|
||||||
|
if (isWithinRoot(realWorkspaceRoot, realCandidate)) {
|
||||||
|
roots.add(realCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...roots];
|
||||||
|
}
|
||||||
|
async function collectThreadDeliveryCandidates(input) {
|
||||||
|
const candidates = [];
|
||||||
|
await walk(input.scanRoot, []);
|
||||||
|
return candidates;
|
||||||
|
async function walk(currentDir, segments) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
input.warnings.push(`cannot read ${safeDisplayPath(input.relativeRoot, currentDir)}: ${String(error)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === "." || entry.name === "..") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const absolutePath = path.join(currentDir, entry.name);
|
||||||
|
const relativeEntryPath = [...segments, entry.name].join("/");
|
||||||
|
if (entry.isSymbolicLink()) {
|
||||||
|
const isDeliveryPath = isThreadDeliveryFile(relativeEntryPath) || THREAD_DELIVERY_DIRS.has(segments[0] ?? entry.name);
|
||||||
|
if (isDeliveryPath) {
|
||||||
|
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (segments.length === 0 && !THREAD_DELIVERY_DIRS.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SKIPPED_DIRS.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await walk(absolutePath, [...segments, entry.name]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const relativePath = safeRelativePath(input.relativeRoot, absolutePath);
|
||||||
|
if (!relativePath || !isThreadDeliveryFile(relativePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const stat = await fs.stat(absolutePath);
|
||||||
|
const realPath = await fs.realpath(absolutePath);
|
||||||
|
if (!isWithinRoot(input.relativeRoot, realPath)) {
|
||||||
|
input.warnings.push(`skipped path outside workspace ${entry.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push({
|
||||||
|
absolutePath: realPath,
|
||||||
|
relativePath,
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
mtimeMs: Math.max(stat.mtimeMs, stat.ctimeMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function isThreadDeliveryFile(relativePath) {
|
||||||
|
const parts = relativePath.split("/");
|
||||||
|
const fileName = parts[parts.length - 1] ?? "";
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return THREAD_DELIVERY_FILE_NAMES.has(fileName);
|
||||||
|
}
|
||||||
|
if (!THREAD_DELIVERY_DIRS.has(parts[0] ?? "")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return THREAD_DELIVERY_EXTENSIONS.has(path.extname(fileName).toLowerCase());
|
||||||
|
}
|
||||||
async function collectCandidates(input) {
|
async function collectCandidates(input) {
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
await walk(input.scanRoot);
|
await walk(input.scanRoot);
|
||||||
@ -354,7 +538,9 @@ async function collectCandidates(input) {
|
|||||||
}
|
}
|
||||||
const absolutePath = path.join(currentDir, entry.name);
|
const absolutePath = path.join(currentDir, entry.name);
|
||||||
if (entry.isSymbolicLink()) {
|
if (entry.isSymbolicLink()) {
|
||||||
|
if (input.warnSkippedSymlinks) {
|
||||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
@ -589,6 +775,12 @@ function contentTypeForPath(relativePath) {
|
|||||||
return "image/gif";
|
return "image/gif";
|
||||||
case ".svg":
|
case ".svg":
|
||||||
return "image/svg+xml";
|
return "image/svg+xml";
|
||||||
|
case ".mp4":
|
||||||
|
return "video/mp4";
|
||||||
|
case ".mov":
|
||||||
|
return "video/quicktime";
|
||||||
|
case ".webm":
|
||||||
|
return "video/webm";
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream";
|
return "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaw-multi-session-plugins",
|
"name": "openclaw-multi-session-plugins",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts",
|
"description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@ -389,6 +389,104 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
expect(result.warnings).toEqual([]);
|
expect(result.warnings).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adopts same-thread delivery files when the prepared task scope is empty", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||||
|
await prepareXWorkmateArtifacts({
|
||||||
|
params: {
|
||||||
|
sessionKey: "draft:1779524982823421-3",
|
||||||
|
runId: "turn-1779685283403237342",
|
||||||
|
},
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
|
const threadRoot = path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:1779524982823421-3");
|
||||||
|
await fs.mkdir(path.join(threadRoot, "renders"), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(threadRoot, "renders", "cloud-native-servicemesh-network.mp4"), "mp4");
|
||||||
|
await fs.writeFile(path.join(threadRoot, "DELIVERY.md"), "delivered");
|
||||||
|
await fs.writeFile(path.join(threadRoot, "scratch.txt"), "scratch");
|
||||||
|
await fs.symlink(threadRoot, path.join(threadRoot, "venv"));
|
||||||
|
await fs.mkdir(path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:other", "renders"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:other", "renders", "other.mp4"),
|
||||||
|
"other",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await exportXWorkmateArtifacts({
|
||||||
|
params: {
|
||||||
|
sessionKey: "draft:1779524982823421-3",
|
||||||
|
runId: "turn-1779685283403237342",
|
||||||
|
sinceUnixMs: Date.now() + 10_000,
|
||||||
|
},
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.artifactScope).toBe("tasks/draft_1779524982823421-3/turn-1779685283403237342");
|
||||||
|
expect(result.artifacts.map((entry) => entry.relativePath).sort()).toEqual([
|
||||||
|
"DELIVERY.md",
|
||||||
|
"renders/cloud-native-servicemesh-network.mp4",
|
||||||
|
]);
|
||||||
|
expect(result.artifacts.map((entry) => entry.contentType).sort()).toEqual([
|
||||||
|
"text/markdown",
|
||||||
|
"video/mp4",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
await fs.readFile(
|
||||||
|
path.join(
|
||||||
|
root,
|
||||||
|
"tasks",
|
||||||
|
"draft_1779524982823421-3",
|
||||||
|
"turn-1779685283403237342",
|
||||||
|
"renders",
|
||||||
|
"cloud-native-servicemesh-network.mp4",
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
).toBe("mp4");
|
||||||
|
await expect(
|
||||||
|
fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "scratch.txt")),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "renders", "other.mp4")),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not adopt same-thread delivery files without a current-run timestamp", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||||
|
await prepareXWorkmateArtifacts({
|
||||||
|
params: {
|
||||||
|
sessionKey: "draft:1779524982823421-3",
|
||||||
|
runId: "turn-1779685283403237342",
|
||||||
|
},
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
|
const threadRoot = path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:1779524982823421-3");
|
||||||
|
await fs.mkdir(path.join(threadRoot, "renders"), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(threadRoot, "renders", "cloud-native-servicemesh-network.mp4"), "mp4");
|
||||||
|
|
||||||
|
const result = await exportXWorkmateArtifacts({
|
||||||
|
params: {
|
||||||
|
sessionKey: "draft:1779524982823421-3",
|
||||||
|
runId: "turn-1779685283403237342",
|
||||||
|
},
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.artifacts).toEqual([]);
|
||||||
|
await expect(
|
||||||
|
fs.stat(
|
||||||
|
path.join(
|
||||||
|
root,
|
||||||
|
"tasks",
|
||||||
|
"draft_1779524982823421-3",
|
||||||
|
"turn-1779685283403237342",
|
||||||
|
"renders",
|
||||||
|
"cloud-native-servicemesh-network.mp4",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("exports concurrent task scopes independently", async () => {
|
it("exports concurrent task scopes independently", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||||
const prepared = await Promise.all([
|
const prepared = await Promise.all([
|
||||||
|
|||||||
@ -21,6 +21,35 @@ const SKIPPED_DIRS = new Set([
|
|||||||
"node_modules",
|
"node_modules",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const THREAD_DELIVERY_FILE_NAMES = new Set([
|
||||||
|
"DELIVERY.md",
|
||||||
|
"delivery.md",
|
||||||
|
"ffprobe.json",
|
||||||
|
"video.config.json",
|
||||||
|
"index.html",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const THREAD_DELIVERY_DIRS = new Set([
|
||||||
|
"renders",
|
||||||
|
"render",
|
||||||
|
"exports",
|
||||||
|
"output",
|
||||||
|
"outputs",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const THREAD_DELIVERY_EXTENSIONS = new Set([
|
||||||
|
".mp4",
|
||||||
|
".mov",
|
||||||
|
".webm",
|
||||||
|
".pdf",
|
||||||
|
".docx",
|
||||||
|
".pptx",
|
||||||
|
".xlsx",
|
||||||
|
".md",
|
||||||
|
".html",
|
||||||
|
".json",
|
||||||
|
]);
|
||||||
|
|
||||||
export type XWorkmateArtifact = {
|
export type XWorkmateArtifact = {
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -167,6 +196,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
|||||||
relativeRoot: scopeRoot,
|
relativeRoot: scopeRoot,
|
||||||
sinceUnixMs,
|
sinceUnixMs,
|
||||||
skipTaskScopeRoot: false,
|
skipTaskScopeRoot: false,
|
||||||
|
warnSkippedSymlinks: true,
|
||||||
warnings,
|
warnings,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
@ -181,7 +211,18 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
|||||||
warnings,
|
warnings,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const candidates = [...scopedCandidates, ...adoptedCandidates];
|
const threadDeliveryCandidates =
|
||||||
|
sinceUnixMs > 0 && scopedCandidates.length === 0 && adoptedCandidates.length === 0
|
||||||
|
? await adoptThreadWorkspaceDeliveryCandidatesIntoScope({
|
||||||
|
workspaceRoot,
|
||||||
|
scopeRoot,
|
||||||
|
artifactScope,
|
||||||
|
sessionKey,
|
||||||
|
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const candidates = [...scopedCandidates, ...adoptedCandidates, ...threadDeliveryCandidates];
|
||||||
if (!scopePrepared && candidates.length === 0) {
|
if (!scopePrepared && candidates.length === 0) {
|
||||||
warnings.push("artifact scope is not prepared for this task run");
|
warnings.push("artifact scope is not prepared for this task run");
|
||||||
}
|
}
|
||||||
@ -415,6 +456,7 @@ async function adoptWorkspaceRootCandidatesIntoScope(input: {
|
|||||||
relativeRoot: input.workspaceRoot,
|
relativeRoot: input.workspaceRoot,
|
||||||
sinceUnixMs: input.sinceUnixMs,
|
sinceUnixMs: input.sinceUnixMs,
|
||||||
skipTaskScopeRoot: true,
|
skipTaskScopeRoot: true,
|
||||||
|
warnSkippedSymlinks: false,
|
||||||
warnings: input.warnings,
|
warnings: input.warnings,
|
||||||
});
|
});
|
||||||
const adopted: Candidate[] = [];
|
const adopted: Candidate[] = [];
|
||||||
@ -448,11 +490,173 @@ async function adoptWorkspaceRootCandidatesIntoScope(input: {
|
|||||||
return adopted;
|
return adopted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function adoptThreadWorkspaceDeliveryCandidatesIntoScope(input: {
|
||||||
|
workspaceRoot: string;
|
||||||
|
scopeRoot: string;
|
||||||
|
artifactScope: string;
|
||||||
|
sessionKey: string;
|
||||||
|
existingRelativePaths: Set<string>;
|
||||||
|
warnings: string[];
|
||||||
|
}): Promise<Candidate[]> {
|
||||||
|
const threadRoots = await resolveCurrentThreadWorkspaceRoots(input.workspaceRoot, input.sessionKey);
|
||||||
|
const adopted: Candidate[] = [];
|
||||||
|
for (const threadRoot of threadRoots) {
|
||||||
|
const candidates = await collectThreadDeliveryCandidates({
|
||||||
|
scanRoot: threadRoot,
|
||||||
|
relativeRoot: threadRoot,
|
||||||
|
warnings: input.warnings,
|
||||||
|
});
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (input.existingRelativePaths.has(candidate.relativePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep));
|
||||||
|
if (!isWithinRoot(input.scopeRoot, targetPath)) {
|
||||||
|
input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (await fileExists(targetPath)) {
|
||||||
|
input.existingRelativePaths.add(candidate.relativePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fs.copyFile(candidate.absolutePath, targetPath);
|
||||||
|
const stat = await fs.stat(targetPath);
|
||||||
|
const realPath = await fs.realpath(targetPath);
|
||||||
|
adopted.push({
|
||||||
|
absolutePath: realPath,
|
||||||
|
relativePath: candidate.relativePath,
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
mtimeMs: candidate.mtimeMs,
|
||||||
|
artifactScope: input.artifactScope,
|
||||||
|
scopeKind: "task",
|
||||||
|
});
|
||||||
|
input.existingRelativePaths.add(candidate.relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return adopted;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCurrentThreadWorkspaceRoots(workspaceRoot: string, sessionKey: string): Promise<string[]> {
|
||||||
|
const roots = new Set<string>();
|
||||||
|
const realWorkspaceRoot = await fs.realpath(workspaceRoot);
|
||||||
|
if (path.basename(realWorkspaceRoot) === sessionKey) {
|
||||||
|
roots.add(realWorkspaceRoot);
|
||||||
|
}
|
||||||
|
const ownerRoots = [
|
||||||
|
path.join(realWorkspaceRoot, "owners", "local", "user"),
|
||||||
|
path.join(realWorkspaceRoot, "owners", "remote", "user"),
|
||||||
|
];
|
||||||
|
for (const ownerRoot of ownerRoots) {
|
||||||
|
if (!(await directoryExists(ownerRoot))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ownerEntries;
|
||||||
|
try {
|
||||||
|
ownerEntries = await fs.readdir(ownerRoot, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const ownerEntry of ownerEntries) {
|
||||||
|
if (!ownerEntry.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = path.join(ownerRoot, ownerEntry.name, "threads", sessionKey);
|
||||||
|
if (!(await directoryExists(candidate))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const realCandidate = await fs.realpath(candidate);
|
||||||
|
if (isWithinRoot(realWorkspaceRoot, realCandidate)) {
|
||||||
|
roots.add(realCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...roots];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectThreadDeliveryCandidates(input: {
|
||||||
|
scanRoot: string;
|
||||||
|
relativeRoot: string;
|
||||||
|
warnings: string[];
|
||||||
|
}): Promise<Candidate[]> {
|
||||||
|
const candidates: Candidate[] = [];
|
||||||
|
await walk(input.scanRoot, []);
|
||||||
|
return candidates;
|
||||||
|
|
||||||
|
async function walk(currentDir: string, segments: string[]): Promise<void> {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
input.warnings.push(`cannot read ${safeDisplayPath(input.relativeRoot, currentDir)}: ${String(error)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === "." || entry.name === "..") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const absolutePath = path.join(currentDir, entry.name);
|
||||||
|
const relativeEntryPath = [...segments, entry.name].join("/");
|
||||||
|
if (entry.isSymbolicLink()) {
|
||||||
|
const isDeliveryPath =
|
||||||
|
isThreadDeliveryFile(relativeEntryPath) || THREAD_DELIVERY_DIRS.has(segments[0] ?? entry.name);
|
||||||
|
if (isDeliveryPath) {
|
||||||
|
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (segments.length === 0 && !THREAD_DELIVERY_DIRS.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SKIPPED_DIRS.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await walk(absolutePath, [...segments, entry.name]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const relativePath = safeRelativePath(input.relativeRoot, absolutePath);
|
||||||
|
if (!relativePath || !isThreadDeliveryFile(relativePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const stat = await fs.stat(absolutePath);
|
||||||
|
const realPath = await fs.realpath(absolutePath);
|
||||||
|
if (!isWithinRoot(input.relativeRoot, realPath)) {
|
||||||
|
input.warnings.push(`skipped path outside workspace ${entry.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push({
|
||||||
|
absolutePath: realPath,
|
||||||
|
relativePath,
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
mtimeMs: Math.max(stat.mtimeMs, stat.ctimeMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isThreadDeliveryFile(relativePath: string): boolean {
|
||||||
|
const parts = relativePath.split("/");
|
||||||
|
const fileName = parts[parts.length - 1] ?? "";
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return THREAD_DELIVERY_FILE_NAMES.has(fileName);
|
||||||
|
}
|
||||||
|
if (!THREAD_DELIVERY_DIRS.has(parts[0] ?? "")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return THREAD_DELIVERY_EXTENSIONS.has(path.extname(fileName).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
async function collectCandidates(input: {
|
async function collectCandidates(input: {
|
||||||
scanRoot: string;
|
scanRoot: string;
|
||||||
relativeRoot: string;
|
relativeRoot: string;
|
||||||
sinceUnixMs: number;
|
sinceUnixMs: number;
|
||||||
skipTaskScopeRoot: boolean;
|
skipTaskScopeRoot: boolean;
|
||||||
|
warnSkippedSymlinks: boolean;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}): Promise<Candidate[]> {
|
}): Promise<Candidate[]> {
|
||||||
const candidates: Candidate[] = [];
|
const candidates: Candidate[] = [];
|
||||||
@ -474,7 +678,9 @@ async function collectCandidates(input: {
|
|||||||
}
|
}
|
||||||
const absolutePath = path.join(currentDir, entry.name);
|
const absolutePath = path.join(currentDir, entry.name);
|
||||||
if (entry.isSymbolicLink()) {
|
if (entry.isSymbolicLink()) {
|
||||||
|
if (input.warnSkippedSymlinks) {
|
||||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
@ -737,6 +943,12 @@ function contentTypeForPath(relativePath: string): string {
|
|||||||
return "image/gif";
|
return "image/gif";
|
||||||
case ".svg":
|
case ".svg":
|
||||||
return "image/svg+xml";
|
return "image/svg+xml";
|
||||||
|
case ".mp4":
|
||||||
|
return "video/mp4";
|
||||||
|
case ".mov":
|
||||||
|
return "video/quicktime";
|
||||||
|
case ".webm":
|
||||||
|
return "video/webm";
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream";
|
return "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user