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
|
||||
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
|
||||
to `tasks/<session>/<run>`. 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.
|
||||
to `tasks/<session>/<run>`.
|
||||
|
||||
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
|
||||
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.
|
||||
- `.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.
|
||||
- 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.
|
||||
- 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>`.
|
||||
|
||||
196
dist/src/exportArtifacts.js
vendored
196
dist/src/exportArtifacts.js
vendored
@ -18,6 +18,32 @@ const SKIPPED_DIRS = new Set([
|
||||
"dist",
|
||||
"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) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
@ -86,6 +112,7 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs,
|
||||
skipTaskScopeRoot: false,
|
||||
warnSkippedSymlinks: true,
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
@ -99,7 +126,17 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
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) {
|
||||
warnings.push("artifact scope is not prepared for this task run");
|
||||
}
|
||||
@ -302,6 +339,7 @@ async function adoptWorkspaceRootCandidatesIntoScope(input) {
|
||||
relativeRoot: input.workspaceRoot,
|
||||
sinceUnixMs: input.sinceUnixMs,
|
||||
skipTaskScopeRoot: true,
|
||||
warnSkippedSymlinks: false,
|
||||
warnings: input.warnings,
|
||||
});
|
||||
const adopted = [];
|
||||
@ -334,6 +372,152 @@ async function adoptWorkspaceRootCandidatesIntoScope(input) {
|
||||
}
|
||||
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) {
|
||||
const candidates = [];
|
||||
await walk(input.scanRoot);
|
||||
@ -354,7 +538,9 @@ async function collectCandidates(input) {
|
||||
}
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||
if (input.warnSkippedSymlinks) {
|
||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
@ -589,6 +775,12 @@ function contentTypeForPath(relativePath) {
|
||||
return "image/gif";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".mp4":
|
||||
return "video/mp4";
|
||||
case ".mov":
|
||||
return "video/quicktime";
|
||||
case ".webm":
|
||||
return "video/webm";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-multi-session-plugins",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@ -389,6 +389,104 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
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 () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const prepared = await Promise.all([
|
||||
|
||||
@ -21,6 +21,35 @@ const SKIPPED_DIRS = new Set([
|
||||
"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 = {
|
||||
relativePath: string;
|
||||
label: string;
|
||||
@ -167,6 +196,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs,
|
||||
skipTaskScopeRoot: false,
|
||||
warnSkippedSymlinks: true,
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
@ -181,7 +211,18 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
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) {
|
||||
warnings.push("artifact scope is not prepared for this task run");
|
||||
}
|
||||
@ -415,6 +456,7 @@ async function adoptWorkspaceRootCandidatesIntoScope(input: {
|
||||
relativeRoot: input.workspaceRoot,
|
||||
sinceUnixMs: input.sinceUnixMs,
|
||||
skipTaskScopeRoot: true,
|
||||
warnSkippedSymlinks: false,
|
||||
warnings: input.warnings,
|
||||
});
|
||||
const adopted: Candidate[] = [];
|
||||
@ -448,11 +490,173 @@ async function adoptWorkspaceRootCandidatesIntoScope(input: {
|
||||
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: {
|
||||
scanRoot: string;
|
||||
relativeRoot: string;
|
||||
sinceUnixMs: number;
|
||||
skipTaskScopeRoot: boolean;
|
||||
warnSkippedSymlinks: boolean;
|
||||
warnings: string[];
|
||||
}): Promise<Candidate[]> {
|
||||
const candidates: Candidate[] = [];
|
||||
@ -474,7 +678,9 @@ async function collectCandidates(input: {
|
||||
}
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||
if (input.warnSkippedSymlinks) {
|
||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
@ -737,6 +943,12 @@ function contentTypeForPath(relativePath: string): string {
|
||||
return "image/gif";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".mp4":
|
||||
return "video/mp4";
|
||||
case ".mov":
|
||||
return "video/quicktime";
|
||||
case ".webm":
|
||||
return "video/webm";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user