fix: export latest session task artifacts

This commit is contained in:
Haitao Pan 2026-05-07 10:10:13 +08:00
parent 1d9887e77b
commit aa0c2323ea
3 changed files with 173 additions and 24 deletions

View File

@ -55,6 +55,7 @@ export async function exportXWorkmateArtifacts(input) {
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const includeContent = optionalBoolean(params.includeContent, true);
const latestIfEmpty = optionalBoolean(params.latestIfEmpty, false);
const latestTaskScopeIfEmpty = optionalBoolean(params.latestTaskScopeIfEmpty, false);
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
@ -76,18 +77,27 @@ export async function exportXWorkmateArtifacts(input) {
});
if (candidates.length === 0 && latestIfEmpty) {
const latestWarnings = [];
const latestCandidates = await collectCandidates({
scanRoot: workspaceRoot,
relativeRoot: workspaceRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: true,
warnings: latestWarnings,
});
const latestCandidates = latestTaskScopeIfEmpty
? await collectLatestSessionTaskCandidates({
workspaceRoot,
sessionKey,
warnings: latestWarnings,
})
: await collectCandidates({
scanRoot: workspaceRoot,
relativeRoot: workspaceRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: true,
warnings: latestWarnings,
});
if (latestCandidates.length > 0) {
warnings.push(...latestWarnings);
if (scopedExport) {
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
}
else if (latestTaskScopeIfEmpty) {
warnings.push("workspace export is empty; exported latest session task files instead");
}
candidates = latestCandidates;
scopeKind = "workspace-latest";
}
@ -106,6 +116,8 @@ export async function exportXWorkmateArtifacts(input) {
}
const bytes = await fs.readFile(candidate.absolutePath);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const artifactScopeForCandidate = candidate.artifactScope || (scopeKind === "task" && artifactScope ? artifactScope : "");
const scopeKindForCandidate = candidate.scopeKind || scopeKind;
const artifact = {
relativePath: candidate.relativePath,
label: path.posix.basename(candidate.relativePath),
@ -115,16 +127,18 @@ export async function exportXWorkmateArtifacts(input) {
artifactRef: signArtifactRef({
v: 1,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
scopeKind: scopeKindForCandidate,
...(scopeKindForCandidate === "task" && artifactScopeForCandidate
? { artifactScope: artifactScopeForCandidate }
: {}),
relativePath: candidate.relativePath,
sizeBytes: bytes.byteLength,
sha256,
}, pluginConfig),
scopeKind,
scopeKind: scopeKindForCandidate,
};
if (scopeKind === "task" && artifactScope) {
artifact.artifactScope = artifactScope;
if (scopeKindForCandidate === "task" && artifactScopeForCandidate) {
artifact.artifactScope = artifactScopeForCandidate;
}
if (includeContent && bytes.byteLength <= maxInlineBytes) {
artifact.encoding = "base64";
@ -333,6 +347,44 @@ async function collectCandidates(input) {
}
}
}
async function collectLatestSessionTaskCandidates(input) {
const sessionScope = [TASK_SCOPE_ROOT, safeScopeSegment(input.sessionKey)].join("/");
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
let entries;
try {
entries = await fs.readdir(sessionRoot, { withFileTypes: true });
}
catch {
return [];
}
const candidates = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "." || entry.name === "..") {
continue;
}
const artifactScope = [sessionScope, entry.name].join("/");
let scopeRoot;
try {
scopeRoot = resolveScopeRoot(input.workspaceRoot, artifactScope);
}
catch {
continue;
}
const scopedCandidates = await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: false,
warnings: input.warnings,
});
candidates.push(...scopedCandidates.map((candidate) => ({
...candidate,
artifactScope,
scopeKind: "task",
})));
}
return candidates;
}
function artifactScopeFor(sessionKey, runId) {
return [
TASK_SCOPE_ROOT,

View File

@ -171,6 +171,44 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.warnings).toContain("scoped artifact directory is empty; exported latest workspace files instead");
});
it("falls back to latest session task files when requested", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
const previousTask = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-previous" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.pdf"), "pdf");
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.docx"), "docx");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-follow-up",
sinceUnixMs: Date.now() + 10_000,
latestIfEmpty: true,
latestTaskScopeIfEmpty: true,
},
pluginConfig: { workspaceDir: root },
});
expect(result.scopeKind).toBe("workspace-latest");
expect(result.artifactScope).toBeUndefined();
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual([
"k8s-networking.docx",
"k8s-networking.pdf",
]);
expect(
result.artifacts.map((entry) => ({
artifactScope: entry.artifactScope,
scopeKind: entry.scopeKind,
})),
).toEqual([
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
]);
expect(result.warnings).toContain("workspace export is empty; exported latest session task files instead");
});
it("leaves oversized artifacts out of inline content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
await fs.writeFile(path.join(root, "large.pdf"), Buffer.from("large-content"));

View File

@ -87,6 +87,8 @@ type Candidate = {
relativePath: string;
sizeBytes: number;
mtimeMs: number;
artifactScope?: string;
scopeKind?: XWorkmateArtifactScopeKind;
};
export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactPrepare> {
@ -132,6 +134,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const includeContent = optionalBoolean(params.includeContent, true);
const latestIfEmpty = optionalBoolean(params.latestIfEmpty, false);
const latestTaskScopeIfEmpty = optionalBoolean(params.latestTaskScopeIfEmpty, false);
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
@ -154,17 +157,25 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
if (candidates.length === 0 && latestIfEmpty) {
const latestWarnings: string[] = [];
const latestCandidates = await collectCandidates({
scanRoot: workspaceRoot,
relativeRoot: workspaceRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: true,
warnings: latestWarnings,
});
const latestCandidates = latestTaskScopeIfEmpty
? await collectLatestSessionTaskCandidates({
workspaceRoot,
sessionKey,
warnings: latestWarnings,
})
: await collectCandidates({
scanRoot: workspaceRoot,
relativeRoot: workspaceRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: true,
warnings: latestWarnings,
});
if (latestCandidates.length > 0) {
warnings.push(...latestWarnings);
if (scopedExport) {
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
} else if (latestTaskScopeIfEmpty) {
warnings.push("workspace export is empty; exported latest session task files instead");
}
candidates = latestCandidates;
scopeKind = "workspace-latest";
@ -186,6 +197,9 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
}
const bytes = await fs.readFile(candidate.absolutePath);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const artifactScopeForCandidate =
candidate.artifactScope || (scopeKind === "task" && artifactScope ? artifactScope : "");
const scopeKindForCandidate = candidate.scopeKind || scopeKind;
const artifact: XWorkmateArtifact = {
relativePath: candidate.relativePath,
label: path.posix.basename(candidate.relativePath),
@ -196,18 +210,20 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
{
v: 1,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
scopeKind: scopeKindForCandidate,
...(scopeKindForCandidate === "task" && artifactScopeForCandidate
? { artifactScope: artifactScopeForCandidate }
: {}),
relativePath: candidate.relativePath,
sizeBytes: bytes.byteLength,
sha256,
},
pluginConfig,
),
scopeKind,
scopeKind: scopeKindForCandidate,
};
if (scopeKind === "task" && artifactScope) {
artifact.artifactScope = artifactScope;
if (scopeKindForCandidate === "task" && artifactScopeForCandidate) {
artifact.artifactScope = artifactScopeForCandidate;
}
if (includeContent && bytes.byteLength <= maxInlineBytes) {
artifact.encoding = "base64";
@ -441,6 +457,49 @@ async function collectCandidates(input: {
}
}
async function collectLatestSessionTaskCandidates(input: {
workspaceRoot: string;
sessionKey: string;
warnings: string[];
}): Promise<Candidate[]> {
const sessionScope = [TASK_SCOPE_ROOT, safeScopeSegment(input.sessionKey)].join("/");
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
let entries;
try {
entries = await fs.readdir(sessionRoot, { withFileTypes: true });
} catch {
return [];
}
const candidates: Candidate[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "." || entry.name === "..") {
continue;
}
const artifactScope = [sessionScope, entry.name].join("/");
let scopeRoot: string;
try {
scopeRoot = resolveScopeRoot(input.workspaceRoot, artifactScope);
} catch {
continue;
}
const scopedCandidates = await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: false,
warnings: input.warnings,
});
candidates.push(
...scopedCandidates.map((candidate) => ({
...candidate,
artifactScope,
scopeKind: "task" as const,
})),
);
}
return candidates;
}
function artifactScopeFor(sessionKey: string, runId: string): string {
return [
TASK_SCOPE_ROOT,