refactor(artifacts): reconcile workspace artifacts at completion
This commit is contained in:
1 parent
2ddc67cc3d
commit
fd86fc6668
17 files changed
+114
-436
No files matched your search
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RuntimeBridge } from "../../src/main/agent-runtime";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import type { CredentialResolver } from "../../src/main/credentials";
|
||||
import { workerRpcMethodSchema } from "../../src/main/worker/entry/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -108,42 +109,8 @@ async function createHarness() {
|
||||
}
|
||||
|
||||
describe("artifact projection durable ack", () => {
|
||||
it("persists afterToolCall projection outcomes to the run event tail", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
await mkdir(join(harness.workspacePath, "reports"), { recursive: true });
|
||||
await writeFile(join(harness.workspacePath, "reports", "hook.md"), "hook artifact", "utf8");
|
||||
|
||||
const response = await harness.bridge.handleRpc({
|
||||
rpcId: "after-tool",
|
||||
method: "afterToolCall",
|
||||
runId: harness.runId,
|
||||
payload: {
|
||||
toolName: "write",
|
||||
toolCallId: "tool-call-hook",
|
||||
args: { path: "reports/hook.md" },
|
||||
resultContent: [],
|
||||
resultDetails: null,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const event = harness.db.core.runs.listRunEvents(harness.runId, harness.companyId)[0];
|
||||
expect(event).toMatchObject({
|
||||
eventType: "artifact_projection",
|
||||
level: "info",
|
||||
});
|
||||
expect(event?.payload).toMatchObject({
|
||||
phase: "after_tool_call",
|
||||
toolName: "write",
|
||||
toolCallId: "tool-call-hook",
|
||||
workspaceMode: "none",
|
||||
counts: { inserted: 1, updated: 0, retired: 0, skipped: 0 },
|
||||
});
|
||||
} finally {
|
||||
harness.db.close();
|
||||
}
|
||||
it("keeps artifact projection out of the per-tool RPC method surface", () => {
|
||||
expect(workerRpcMethodSchema.safeParse("afterToolCall").success).toBe(false);
|
||||
});
|
||||
|
||||
it("runs completion reconciliation before resource release for non-exclusive observation workspaces", async () => {
|
||||
@@ -170,6 +137,8 @@ describe("artifact projection durable ack", () => {
|
||||
workspaceMode: "none",
|
||||
counts: { inserted: 1, updated: 0, retired: 0, skipped: 0 },
|
||||
});
|
||||
expect(events[0]?.payload).not.toHaveProperty("toolName");
|
||||
expect(events[0]?.payload).not.toHaveProperty("toolCallId");
|
||||
} finally {
|
||||
harness.db.close();
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ describe("pi runtime integration contract", () => {
|
||||
expect(agent.state.tools).toEqual([]);
|
||||
expect(agent.state.messages).toEqual([]);
|
||||
expect(typeof agent.beforeToolCall).toBe("function");
|
||||
expect(typeof agent.afterToolCall).toBe("function");
|
||||
expect(typeof agent.transformContext).toBe("function");
|
||||
expect(agent.afterToolCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the coding-tool surface expected by worker runs", () => {
|
||||
@@ -133,12 +133,10 @@ describe("pi runtime integration contract", () => {
|
||||
it("keeps the aggregated hook bundle assignable to pi Agent options at runtime", () => {
|
||||
const hooks = buildAgentHooks({ runId: "run-contract", rpc: createRpc() });
|
||||
expect(Object.keys(hooks).sort()).toEqual([
|
||||
"afterToolCall",
|
||||
"beforeToolCall",
|
||||
"transformContext",
|
||||
]);
|
||||
expect(typeof hooks.beforeToolCall).toBe("function");
|
||||
expect(typeof hooks.afterToolCall).toBe("function");
|
||||
expect(typeof hooks.transformContext).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import {
|
||||
projectToolExecutionToWorkProduct,
|
||||
reconcileWorkspaceArtifacts,
|
||||
type ArtifactProjectorRunContext,
|
||||
} from "../../src/main/agent-runtime/artifact-projector";
|
||||
@@ -237,7 +236,7 @@ describe("work_products archived visibility", () => {
|
||||
});
|
||||
|
||||
describe("work_products bulk retirement", () => {
|
||||
it("registers bash artifacts from non-exclusive observation workspaces", async () => {
|
||||
it("registers changed artifacts from non-exclusive observation workspaces", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const ctx: ArtifactProjectorRunContext = {
|
||||
@@ -257,12 +256,7 @@ describe("work_products bulk retirement", () => {
|
||||
await mkdir(join(harness.workspacePath, "reports"), { recursive: true });
|
||||
await writeFile(join(harness.workspacePath, "reports", "non-exclusive.md"), "observed", "utf8");
|
||||
|
||||
const outcomes = projectToolExecutionToWorkProduct(harness.db, ctx, {
|
||||
toolName: "bash",
|
||||
args: { command: "printf observed > reports/non-exclusive.md" },
|
||||
result: null,
|
||||
isError: false,
|
||||
});
|
||||
const outcomes = reconcileWorkspaceArtifacts(harness.db, ctx);
|
||||
|
||||
expect(outcomes).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -280,6 +274,44 @@ describe("work_products bulk retirement", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("registers every changed artifact past the previous 50-path budget", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const paths: string[] = [];
|
||||
await mkdir(join(harness.workspacePath, "bulk"), { recursive: true });
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
const rel = `bulk/new-${i}.md`;
|
||||
paths.push(rel);
|
||||
await writeFile(join(harness.workspacePath, rel), `artifact ${i}`, "utf8");
|
||||
}
|
||||
|
||||
const outcomes = reconcileWorkspaceArtifacts(harness.db, {
|
||||
runId: harness.runId,
|
||||
agentId: harness.agentId,
|
||||
companyId: harness.companyId,
|
||||
taskId: harness.taskId,
|
||||
workspaceId: null,
|
||||
workspacePath: harness.workspacePath,
|
||||
workspaceMode: "none",
|
||||
workspaceBaseline: {
|
||||
mtimeByRelPath: new Map<string, number>(),
|
||||
capturedAt: Date.now(),
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(outcomes.filter((o) => o.kind === "inserted")).toHaveLength(120);
|
||||
const visible = new Set(
|
||||
harness.db.projection.snapshot.listSnapshot().workProducts.map((entry) => entry.externalId),
|
||||
);
|
||||
for (const path of paths) {
|
||||
expect(visible.has(path)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retires deleted artifacts from non-exclusive observation workspaces", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,7 @@ function createRpc(response: { ok: true; result: unknown } | { ok: false; error:
|
||||
}
|
||||
|
||||
describe("agent hook RPC policy", () => {
|
||||
it("fails afterToolCall when main cannot acknowledge artifact projection", async () => {
|
||||
it("fails beforeToolCall closed when main cannot answer approval policy", async () => {
|
||||
const rpc = createRpc({ ok: false, error: "RuntimeBridge unavailable" });
|
||||
const hooks = buildAgentHooks({
|
||||
runId: "11111111-1111-4111-8111-111111111111",
|
||||
@@ -17,12 +17,21 @@ describe("agent hook RPC policy", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
hooks.afterToolCall({
|
||||
toolCall: { name: "write", id: "tool-call-1" },
|
||||
hooks.beforeToolCall({
|
||||
toolCall: { name: "bash", id: "tool-call-1" },
|
||||
args: { path: "report.md" },
|
||||
result: { content: [], details: null },
|
||||
isError: false,
|
||||
} as unknown as Parameters<typeof hooks.afterToolCall>[0]),
|
||||
).rejects.toThrow("afterToolCall RPC failed: RuntimeBridge unavailable");
|
||||
} as unknown as Parameters<typeof hooks.beforeToolCall>[0]),
|
||||
).rejects.toThrow("beforeToolCall RPC failed: RuntimeBridge unavailable");
|
||||
});
|
||||
|
||||
it("installs no per-tool artifact projection hook", () => {
|
||||
const rpc = createRpc({ ok: true, result: { block: false, reason: null } });
|
||||
const hooks = buildAgentHooks({
|
||||
runId: "11111111-1111-4111-8111-111111111111",
|
||||
rpc,
|
||||
});
|
||||
|
||||
expect(Object.keys(hooks).sort()).toEqual(["beforeToolCall", "transformContext"]);
|
||||
expect("afterToolCall" in hooks).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppDatabase } from "../../src/main/database";
|
||||
import {
|
||||
captureBaselineIfRequired,
|
||||
projectToolExecutionToWorkProduct,
|
||||
reconcileWorkspaceArtifacts,
|
||||
type ArtifactProjectorRunContext,
|
||||
} from "../../src/main/agent-runtime/artifact-projector";
|
||||
@@ -46,47 +45,7 @@ describe("artifact projector error taxonomy", () => {
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("classifies missing filesystem artifact projection failures", async () => {
|
||||
const logger = createLogger();
|
||||
const workspacePath = await createTempDir("agentcompany-artifact-errors-");
|
||||
const absPath = join(workspacePath, "missing.md");
|
||||
const ctx: ArtifactProjectorRunContext = {
|
||||
runId: "run-artifact-error",
|
||||
agentId: "agent-artifact-error",
|
||||
companyId: "company-artifact-error",
|
||||
taskId: "task-artifact-error",
|
||||
workspaceId: "workspace-artifact-error",
|
||||
workspacePath,
|
||||
workspaceMode: "exclusive",
|
||||
logger,
|
||||
workspaceBaseline: null,
|
||||
};
|
||||
|
||||
const outcomes = projectToolExecutionToWorkProduct(
|
||||
{} as AppDatabase,
|
||||
ctx,
|
||||
{
|
||||
toolName: "write",
|
||||
args: { path: "missing.md" },
|
||||
result: null,
|
||||
isError: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(outcomes).toEqual([{ kind: "skipped", reason: "path_missing" }]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"[artifact.fs.path_stat_failed] Failed to stat filesystem artifact before projection.",
|
||||
{
|
||||
workspacePath,
|
||||
absPath,
|
||||
runId: "run-artifact-error",
|
||||
},
|
||||
expect.stringContaining("cause="),
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips broad bash projection when the run has no change source", async () => {
|
||||
it("skips completion reconciliation when the run has no change source", async () => {
|
||||
const workspacePath = await createTempDir("agentcompany-shared-artifacts-");
|
||||
const ctx: ArtifactProjectorRunContext = {
|
||||
runId: "run-shared",
|
||||
@@ -100,12 +59,6 @@ describe("artifact projector error taxonomy", () => {
|
||||
workspaceBaseline: null,
|
||||
};
|
||||
|
||||
expect(projectToolExecutionToWorkProduct({} as AppDatabase, ctx, {
|
||||
toolName: "bash",
|
||||
args: { command: "touch report.md" },
|
||||
result: null,
|
||||
isError: false,
|
||||
})).toEqual([{ kind: "skipped", reason: "no_baseline" }]);
|
||||
expect(reconcileWorkspaceArtifacts({} as AppDatabase, ctx)).toEqual([
|
||||
{ kind: "skipped", reason: "no_baseline" },
|
||||
]);
|
||||
|
||||
Reference in new issue
Block a user