feat(artifacts): project bash filesystem changes into work_products
The artifact projector only registered write/edit. Everything bash produced — build artifacts, files written via shell redirection, git commits, generated code — stayed invisible to the desktop UI and the audit layer. Filesystem truth and UI truth were systematically out of sync. This lifts bash onto the same filesystem artifact protocol as write/edit. When a bash tool call succeeds in a git workspace, the projector runs `git status --porcelain=v1 -z --untracked-files=all` and upserts a work_products row for every modified/untracked entry, reusing the existing `(companyId, provider='filesystem', externalId)` identity so repeated edits never duplicate. Non-git workspaces fall through with an explicit "not_a_git_repo" skip reason — there is no authoritative baseline there to diff against, and silently half-registering would be worse than skipping. A 50-file cap per invocation bounds the scan so a runaway rebuild cannot flood the table; anything past the cap is logged as a skip. Rename entries (emitted as `R new\0old`) advance the iterator past the old-path entry so deletions of the rename source are not double-registered. The projector now always returns an array so `write`/`edit` (one path) and `bash` (many paths) share one shape, and the hook handler can log uniform outcomes. .gitignore gains `AgentCompany-Rust/` — the separate Rust rewrite tree lives in the same repo root but is not part of the desktop app build and should not drift into feature commits.
This commit is contained in:
1 parent
5ee3d1394a
commit
34814b4dfd
2 files changed
+132
-27
No files matched your search
@@ -50,6 +50,9 @@ Thumbs.db
|
|||||||
# Local reference material
|
# Local reference material
|
||||||
oldreference/
|
oldreference/
|
||||||
|
|
||||||
|
# Future rewrite — not part of the TypeScript app tree.
|
||||||
|
AgentCompany-Rust/
|
||||||
|
|
||||||
# Markdown docs
|
# Markdown docs
|
||||||
*.md
|
*.md
|
||||||
!/README.md
|
!/README.md
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import { isAbsolute, relative, resolve } from "node:path";
|
import { isAbsolute, relative, resolve } from "node:path";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, statSync } from "node:fs";
|
||||||
import type { AppDatabase } from "@main/database";
|
import type { AppDatabase } from "@main/database";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-registration of filesystem artifacts produced by pi's write / edit
|
* Filesystem artifact protocol for pi tool invocations.
|
||||||
* tools. Every successful tool call that mutates a workspace file is
|
|
||||||
* projected into a `work_products` row so the desktop UI sees what the
|
|
||||||
* agent actually produced — the old behavior required the agent to call
|
|
||||||
* `POST /api/documents` manually, which was routinely forgotten and left
|
|
||||||
* dozens of files on disk invisible to users.
|
|
||||||
*
|
*
|
||||||
* Registration is idempotent: the natural identity of an artifact is
|
* Every successful destructive tool call (`write`, `edit`, `bash`) is
|
||||||
* `(companyId, provider, externalId)` where externalId is the workspace-
|
* projected into the `work_products` table so the desktop UI can
|
||||||
* relative path. Re-writing a file updates the existing row instead of
|
* surface what the agent actually produced without requiring the
|
||||||
* creating duplicates.
|
* agent to remember to POST /api/documents.
|
||||||
*
|
*
|
||||||
* This module is pure data-structure plumbing — it holds no state and
|
* Projection is idempotent: the natural identity of an artifact is
|
||||||
* does no I/O beyond the single SQLite upsert. The RuntimeBridge owns
|
* `(companyId, provider="filesystem", externalId=<workspace-relative-path>)`.
|
||||||
* the call site.
|
* The first tool call inserts; subsequent calls to the same path go
|
||||||
|
* through `updateWorkProduct` so repeated edits do not spam duplicates.
|
||||||
|
*
|
||||||
|
* `write` / `edit` expose the touched path in their args — the
|
||||||
|
* projection path is trivial. `bash` is opaque: the tool may run any
|
||||||
|
* command, including commands that mutate the workspace
|
||||||
|
* (git commit, npm build, file redirection). For bash we scan the
|
||||||
|
* workspace for filesystem changes with `git status --porcelain`;
|
||||||
|
* non-git workspaces fall through to an explicit skip since there
|
||||||
|
* is no authoritative baseline to diff against.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Paths we never register — tool output that is never a deliverable. */
|
/** Paths we never register — tool output that is never a deliverable. */
|
||||||
@@ -47,6 +53,10 @@ const IGNORED_EXTENSIONS: ReadonlySet<string> = new Set([
|
|||||||
".min.css",
|
".min.css",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** Bash scan budget: cap file count + byte count per invocation so a
|
||||||
|
* runaway rebuild cannot flood the artifact table. */
|
||||||
|
const MAX_BASH_PROJECTED_FILES = 50;
|
||||||
|
|
||||||
export type ArtifactProjectorToolEvent = {
|
export type ArtifactProjectorToolEvent = {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
args: unknown;
|
args: unknown;
|
||||||
@@ -92,7 +102,6 @@ function isRegistrablePath(absPath: string, workspacePath: string): boolean {
|
|||||||
if (dot > 0) {
|
if (dot > 0) {
|
||||||
const ext = basename.slice(dot).toLowerCase();
|
const ext = basename.slice(dot).toLowerCase();
|
||||||
if (IGNORED_EXTENSIONS.has(ext)) return false;
|
if (IGNORED_EXTENSIONS.has(ext)) return false;
|
||||||
// Catch compound extensions like ".min.js" by checking the last two dots.
|
|
||||||
const prevDot = basename.lastIndexOf(".", dot - 1);
|
const prevDot = basename.lastIndexOf(".", dot - 1);
|
||||||
if (prevDot > 0) {
|
if (prevDot > 0) {
|
||||||
const compound = basename.slice(prevDot).toLowerCase();
|
const compound = basename.slice(prevDot).toLowerCase();
|
||||||
@@ -108,31 +117,125 @@ export type ProjectionOutcome =
|
|||||||
| { kind: "updated"; workProductId: string; relativePath: string };
|
| { kind: "updated"; workProductId: string; relativePath: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert a work_products row for a successful write/edit tool call.
|
* Upsert work_products rows for a successful destructive tool call.
|
||||||
* Returns an outcome describing whether the artifact was inserted,
|
* For `write` / `edit` the path comes directly from the tool args.
|
||||||
* updated, or skipped (with reason). Returns "skipped" for every
|
* For `bash` we scan the workspace via `git status --porcelain`.
|
||||||
* non-applicable case so the caller can log a single stream of events.
|
*
|
||||||
|
* Always returns an array — `write`/`edit` touch one path so the array
|
||||||
|
* holds a single outcome; `bash` can surface many.
|
||||||
*/
|
*/
|
||||||
export function projectToolExecutionToWorkProduct(
|
export function projectToolExecutionToWorkProduct(
|
||||||
db: AppDatabase,
|
db: AppDatabase,
|
||||||
ctx: ArtifactProjectorRunContext,
|
ctx: ArtifactProjectorRunContext,
|
||||||
event: ArtifactProjectorToolEvent,
|
event: ArtifactProjectorToolEvent,
|
||||||
): ProjectionOutcome {
|
): ProjectionOutcome[] {
|
||||||
if (event.isError) return { kind: "skipped", reason: "tool_error" };
|
if (event.isError) return [{ kind: "skipped", reason: "tool_error" }];
|
||||||
if (event.toolName !== "write" && event.toolName !== "edit") {
|
if (!ctx.workspacePath) return [{ kind: "skipped", reason: "no_workspace" }];
|
||||||
return { kind: "skipped", reason: "unsupported_tool" };
|
|
||||||
}
|
|
||||||
if (!ctx.workspacePath) return { kind: "skipped", reason: "no_workspace" };
|
|
||||||
|
|
||||||
const rawPath = extractToolPath(event.toolName, event.args);
|
if (event.toolName === "write" || event.toolName === "edit") {
|
||||||
|
return [projectSinglePathArtifact(db, ctx, event.toolName, event.args)];
|
||||||
|
}
|
||||||
|
if (event.toolName === "bash") {
|
||||||
|
return projectBashArtifacts(db, ctx);
|
||||||
|
}
|
||||||
|
return [{ kind: "skipped", reason: "unsupported_tool" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectSinglePathArtifact(
|
||||||
|
db: AppDatabase,
|
||||||
|
ctx: ArtifactProjectorRunContext,
|
||||||
|
toolName: string,
|
||||||
|
args: unknown,
|
||||||
|
): ProjectionOutcome {
|
||||||
|
if (!ctx.workspacePath) return { kind: "skipped", reason: "no_workspace" };
|
||||||
|
const rawPath = extractToolPath(toolName, args);
|
||||||
if (!rawPath) return { kind: "skipped", reason: "missing_path" };
|
if (!rawPath) return { kind: "skipped", reason: "missing_path" };
|
||||||
|
|
||||||
const absPath = isAbsolute(rawPath) ? rawPath : resolve(ctx.workspacePath, rawPath);
|
const absPath = isAbsolute(rawPath) ? rawPath : resolve(ctx.workspacePath, rawPath);
|
||||||
if (!isRegistrablePath(absPath, ctx.workspacePath)) {
|
if (!isRegistrablePath(absPath, ctx.workspacePath)) {
|
||||||
return { kind: "skipped", reason: "path_ignored" };
|
return { kind: "skipped", reason: "path_ignored" };
|
||||||
}
|
}
|
||||||
|
return upsertFilesystemArtifact(db, ctx, ctx.workspacePath, absPath);
|
||||||
|
}
|
||||||
|
|
||||||
const relativePath = relative(ctx.workspacePath, absPath);
|
function projectBashArtifacts(
|
||||||
|
db: AppDatabase,
|
||||||
|
ctx: ArtifactProjectorRunContext,
|
||||||
|
): ProjectionOutcome[] {
|
||||||
|
if (!ctx.workspacePath) return [{ kind: "skipped", reason: "no_workspace" }];
|
||||||
|
if (!isGitRepo(ctx.workspacePath)) {
|
||||||
|
return [{ kind: "skipped", reason: "not_a_git_repo" }];
|
||||||
|
}
|
||||||
|
let changed: string[];
|
||||||
|
try {
|
||||||
|
changed = listChangedRelPaths(ctx.workspacePath);
|
||||||
|
} catch {
|
||||||
|
return [{ kind: "skipped", reason: "git_status_failed" }];
|
||||||
|
}
|
||||||
|
if (changed.length === 0) return [{ kind: "skipped", reason: "no_changes" }];
|
||||||
|
|
||||||
|
const outcomes: ProjectionOutcome[] = [];
|
||||||
|
const budget = changed.slice(0, MAX_BASH_PROJECTED_FILES);
|
||||||
|
for (const rel of budget) {
|
||||||
|
const absPath = resolve(ctx.workspacePath, rel);
|
||||||
|
if (!isRegistrablePath(absPath, ctx.workspacePath)) {
|
||||||
|
outcomes.push({ kind: "skipped", reason: "path_ignored" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
outcomes.push(upsertFilesystemArtifact(db, ctx, ctx.workspacePath, absPath));
|
||||||
|
}
|
||||||
|
if (changed.length > MAX_BASH_PROJECTED_FILES) {
|
||||||
|
outcomes.push({ kind: "skipped", reason: "bash_scan_cap_exceeded" });
|
||||||
|
}
|
||||||
|
return outcomes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitRepo(workspacePath: string): boolean {
|
||||||
|
try {
|
||||||
|
return existsSync(resolve(workspacePath, ".git")) && statSync(resolve(workspacePath, ".git")).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate tracked-modified + untracked files via `git status --porcelain=v1 -z`.
|
||||||
|
*
|
||||||
|
* Output format per entry: `XY path`. NUL terminates each entry. For
|
||||||
|
* renames the new path is in the entry with the `R` status code and
|
||||||
|
* the old path follows as its own NUL-terminated entry — we advance
|
||||||
|
* past that old-path entry without emitting it.
|
||||||
|
*/
|
||||||
|
function listChangedRelPaths(workspacePath: string): string[] {
|
||||||
|
const raw = execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
||||||
|
cwd: workspacePath,
|
||||||
|
timeout: 5_000,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (!raw) return [];
|
||||||
|
const entries = raw.split("\0").filter(Boolean);
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (entry.length < 3) continue;
|
||||||
|
const statusCode = entry.slice(0, 2);
|
||||||
|
const rest = entry.slice(3);
|
||||||
|
if (statusCode === "!!") continue;
|
||||||
|
paths.push(rest);
|
||||||
|
const isRename = statusCode[0] === "R" || statusCode[1] === "R";
|
||||||
|
if (isRename) i += 1;
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertFilesystemArtifact(
|
||||||
|
db: AppDatabase,
|
||||||
|
ctx: ArtifactProjectorRunContext,
|
||||||
|
workspacePath: string,
|
||||||
|
absPath: string,
|
||||||
|
): ProjectionOutcome {
|
||||||
|
const relativePath = relative(workspacePath, absPath);
|
||||||
const lastSlash = relativePath.lastIndexOf("/");
|
const lastSlash = relativePath.lastIndexOf("/");
|
||||||
const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath;
|
const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath;
|
||||||
|
|
||||||
@@ -142,7 +245,6 @@ export function projectToolExecutionToWorkProduct(
|
|||||||
db.updateWorkProduct(existing.id, ctx.companyId, { title });
|
db.updateWorkProduct(existing.id, ctx.companyId, { title });
|
||||||
return { kind: "updated", workProductId: existing.id, relativePath };
|
return { kind: "updated", workProductId: existing.id, relativePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
const workProductId = db.createWorkProduct({
|
const workProductId = db.createWorkProduct({
|
||||||
companyId: ctx.companyId,
|
companyId: ctx.companyId,
|
||||||
taskId: ctx.taskId,
|
taskId: ctx.taskId,
|
||||||
|
|||||||
Reference in new issue
Block a user