diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index 15aa70d..4da686b 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -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, diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index a52764b..2d49311 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -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")); diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index 1fb2d3a..2df2bed 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -87,6 +87,8 @@ type Candidate = { relativePath: string; sizeBytes: number; mtimeMs: number; + artifactScope?: string; + scopeKind?: XWorkmateArtifactScopeKind; }; export async function prepareXWorkmateArtifacts(input: ExportInput): Promise { @@ -132,6 +134,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise 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 { + 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,